mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge branch 'main' into feat/skills
This commit is contained in:
@@ -88,6 +88,12 @@ npm install -g .
|
||||
brew install qwen-code
|
||||
```
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more.
|
||||
|
||||
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -75,6 +75,8 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// We use TypeScript for React components; prop-types are unnecessary
|
||||
'react/prop-types': 'off',
|
||||
// General Best Practice Rules (subset adapted for flat config)
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
|
||||
'arrow-body-style': ['error', 'as-needed'],
|
||||
@@ -111,10 +113,14 @@ export default tseslint.config(
|
||||
{
|
||||
allow: [
|
||||
'react-dom/test-utils',
|
||||
'react-dom/client',
|
||||
'memfs/lib/volume.js',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
'**/generated/**'
|
||||
'**/generated/**',
|
||||
'./styles/tailwind.css',
|
||||
'./styles/App.css',
|
||||
'./styles/style.css'
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -25,6 +25,14 @@ type PendingRequest = {
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
type UsageMetadata = {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
thoughtsTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
cachedTokens?: number | null;
|
||||
};
|
||||
|
||||
type SessionUpdateNotification = {
|
||||
sessionId?: string;
|
||||
update?: {
|
||||
@@ -39,6 +47,9 @@ type SessionUpdateNotification = {
|
||||
text?: string;
|
||||
};
|
||||
modeId?: string;
|
||||
_meta?: {
|
||||
usage?: UsageMetadata;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -587,4 +598,52 @@ function setupAcpTest(
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('receives usage metadata in agent_message_chunk updates', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp usage metadata');
|
||||
|
||||
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
await sendRequest('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
|
||||
await sendRequest('session/prompt', {
|
||||
sessionId: newSession.sessionId,
|
||||
prompt: [{ type: 'text', text: 'Say "hello".' }],
|
||||
});
|
||||
|
||||
await delay(500);
|
||||
|
||||
// Find updates with usage metadata
|
||||
const updatesWithUsage = sessionUpdates.filter(
|
||||
(u) =>
|
||||
u.update?.sessionUpdate === 'agent_message_chunk' &&
|
||||
u.update?._meta?.usage,
|
||||
);
|
||||
|
||||
expect(updatesWithUsage.length).toBeGreaterThan(0);
|
||||
|
||||
const usage = updatesWithUsage[0].update?._meta?.usage;
|
||||
expect(usage).toBeDefined();
|
||||
expect(
|
||||
typeof usage?.promptTokens === 'number' ||
|
||||
typeof usage?.totalTokens === 'number',
|
||||
).toBe(true);
|
||||
} catch (e) {
|
||||
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
|
||||
throw e;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
1284
package-lock.json
generated
1284
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -93,7 +93,7 @@
|
||||
"eslint-plugin-license-header": "^0.8.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^10.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"globals": "^16.0.0",
|
||||
"google-artifactregistry-auth": "^3.4.0",
|
||||
"husky": "^9.1.7",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "^6.2.3",
|
||||
"ink-gradient": "^3.0.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
|
||||
@@ -316,6 +316,23 @@ export const annotationsSchema = z.object({
|
||||
priority: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export const usageSchema = z.object({
|
||||
promptTokens: z.number().optional().nullable(),
|
||||
completionTokens: z.number().optional().nullable(),
|
||||
thoughtsTokens: z.number().optional().nullable(),
|
||||
totalTokens: z.number().optional().nullable(),
|
||||
cachedTokens: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type Usage = z.infer<typeof usageSchema>;
|
||||
|
||||
export const sessionUpdateMetaSchema = z.object({
|
||||
usage: usageSchema.optional().nullable(),
|
||||
durationMs: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
});
|
||||
@@ -500,10 +517,12 @@ export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
|
||||
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('parses path from ACP ENOENT message (quoted)', async () => {
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/remote/file.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to requested path when none provided', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-2',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.readTextFile('/fallback/path.txt'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/fallback/path.txt',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
limit: null,
|
||||
});
|
||||
|
||||
if (response.content.startsWith('ERROR: ENOENT:')) {
|
||||
// Treat ACP error strings as structured ENOENT errors without
|
||||
// assuming a specific platform format.
|
||||
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
|
||||
const err = new Error(response.content) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
const rawPath = match?.groups?.['path']?.trim();
|
||||
err['path'] = rawPath
|
||||
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
|
||||
: filePath;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
|
||||
@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usage metadata replay', () => {
|
||||
it('should emit usage metadata after assistant message content', async () => {
|
||||
const record: ChatRecord = {
|
||||
uuid: 'assistant-uuid',
|
||||
parentUuid: 'user-uuid',
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'assistant',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hello!' }],
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello!' },
|
||||
});
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: undefined,
|
||||
totalTokens: 150,
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
} from '@google/genai';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
@@ -52,6 +55,9 @@ export class HistoryReplayer {
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'assistant');
|
||||
}
|
||||
if (record.usageMetadata) {
|
||||
await this.replayUsageMetadata(record.usageMetadata);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
@@ -88,11 +94,22 @@ export class HistoryReplayer {
|
||||
toolName: functionName,
|
||||
callId,
|
||||
args: part.functionCall.args as Record<string, unknown>,
|
||||
status: 'in_progress',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays usage metadata.
|
||||
* @param usageMetadata - The usage metadata to replay
|
||||
*/
|
||||
private async replayUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
): Promise<void> {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a tool result record.
|
||||
*/
|
||||
@@ -118,6 +135,54 @@ export class HistoryReplayer {
|
||||
// Note: args aren't stored in tool_result records by default
|
||||
args: undefined,
|
||||
});
|
||||
|
||||
// Special handling: Task tool execution summary contains token usage
|
||||
const { resultDisplay } = result ?? {};
|
||||
if (
|
||||
!!resultDisplay &&
|
||||
typeof resultDisplay === 'object' &&
|
||||
'type' in resultDisplay &&
|
||||
(resultDisplay as { type?: unknown }).type === 'task_execution'
|
||||
) {
|
||||
await this.emitTaskUsageFromResultDisplay(
|
||||
resultDisplay as TaskResultDisplay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits token usage from a TaskResultDisplay execution summary, if present.
|
||||
*/
|
||||
private async emitTaskUsageFromResultDisplay(
|
||||
resultDisplay: TaskResultDisplay,
|
||||
): Promise<void> {
|
||||
const summary = resultDisplay.executionSummary;
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageMetadata: GenerateContentResponseUsageMetadata = {};
|
||||
|
||||
if (Number.isFinite(summary.inputTokens)) {
|
||||
usageMetadata.promptTokenCount = summary.inputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.outputTokens)) {
|
||||
usageMetadata.candidatesTokenCount = summary.outputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.thoughtTokens)) {
|
||||
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.cachedTokens)) {
|
||||
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.totalTokens)) {
|
||||
usageMetadata.totalTokenCount = summary.totalTokens;
|
||||
}
|
||||
|
||||
// Only emit if we captured at least one token metric
|
||||
if (Object.keys(usageMetadata).length > 0) {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, FunctionCall, Part } from '@google/genai';
|
||||
import type {
|
||||
Content,
|
||||
FunctionCall,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
} from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
@@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
/**
|
||||
@@ -79,6 +85,7 @@ export class Session implements SessionContext {
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly planEmitter: PlanEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
|
||||
// Implement SessionContext interface
|
||||
readonly sessionId: string;
|
||||
@@ -96,6 +103,7 @@ export class Session implements SessionContext {
|
||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||
this.planEmitter = new PlanEmitter(this);
|
||||
this.historyReplayer = new HistoryReplayer(this);
|
||||
this.messageEmitter = new MessageEmitter(this);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
@@ -251,6 +259,15 @@ export class Session implements SessionContext {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (usageMetadata) {
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
@@ -522,6 +541,7 @@ export class Session implements SessionContext {
|
||||
callId,
|
||||
toolName: fc.name,
|
||||
args,
|
||||
status: 'in_progress',
|
||||
};
|
||||
await this.toolCallEmitter.emitStart(startParams);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
SubAgentUsageEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { z } from 'zod';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
||||
*/
|
||||
export class SubAgentTracker {
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolStates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -76,6 +79,7 @@ export class SubAgentTracker {
|
||||
private readonly client: acp.Client,
|
||||
) {
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,16 +96,19 @@ export class SubAgentTracker {
|
||||
const onToolCall = this.createToolCallHandler(abortSignal);
|
||||
const onToolResult = this.createToolResultHandler(abortSignal);
|
||||
const onApproval = this.createApprovalHandler(abortSignal);
|
||||
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
|
||||
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
|
||||
return [
|
||||
() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
// Clean up any remaining states
|
||||
this.toolStates.clear();
|
||||
},
|
||||
@@ -252,6 +259,20 @@ export class SubAgentTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for usage metadata events.
|
||||
*/
|
||||
private createUsageMetadataHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentUsageEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts confirmation details to permission options for the client.
|
||||
*/
|
||||
|
||||
@@ -148,4 +148,59 @@ describe('MessageEmitter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUsageMetadata', () => {
|
||||
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
thoughtsTokenCount: 25,
|
||||
totalTokenCount: 175,
|
||||
cachedContentTokenCount: 10,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: 25,
|
||||
totalTokens: 175,
|
||||
cachedTokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include durationMs in _meta when provided', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 1,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'done' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
thoughtsTokens: 2,
|
||||
totalTokens: 17,
|
||||
cachedTokens: 1,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent message chunk.
|
||||
*/
|
||||
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
* Emits usage metadata.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
async emitUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
text: string = '',
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
const usage: Usage = {
|
||||
promptTokens: usageMetadata.promptTokenCount,
|
||||
completionTokens: usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokens: usageMetadata.thoughtsTokenCount,
|
||||
totalTokens: usageMetadata.totalTokenCount,
|
||||
cachedTokens: usageMetadata.cachedContentTokenCount,
|
||||
};
|
||||
|
||||
const meta =
|
||||
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
_meta: meta,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'unknown_tool', // Falls back to tool name
|
||||
content: [],
|
||||
locations: [],
|
||||
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-456',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'edit_file: Test tool description',
|
||||
content: [],
|
||||
locations: [{ path: '/test/file.ts', line: 10 }],
|
||||
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-fail',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'failing_tool', // Fallback to tool name
|
||||
content: [],
|
||||
locations: [], // Fallback to empty
|
||||
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '{"output":"test output"}',
|
||||
text: 'test output',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: '{"output":"Function output"}' },
|
||||
content: { type: 'text', text: 'Function output' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw result',
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: params.callId,
|
||||
status: 'in_progress',
|
||||
status: params.status || 'pending',
|
||||
title,
|
||||
content: [],
|
||||
locations,
|
||||
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
// Handle functionResponse parts - stringify the response
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
try {
|
||||
const responseText = JSON.stringify(part.functionResponse.response);
|
||||
const resp = part.functionResponse.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outputField = resp['output'];
|
||||
const errorField = resp['error'];
|
||||
const responseText =
|
||||
typeof outputField === 'string'
|
||||
? outputField
|
||||
: typeof errorField === 'string'
|
||||
? errorField
|
||||
: JSON.stringify(resp);
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ToolCallStartParams {
|
||||
callId: string;
|
||||
/** Arguments passed to the tool */
|
||||
args?: Record<string, unknown>;
|
||||
/** Status of the tool call */
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => {
|
||||
|
||||
const expectedSubstrings = [
|
||||
`set -eEuo pipefail`,
|
||||
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
|
||||
`fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`,
|
||||
];
|
||||
|
||||
for (const substring of expectedSubstrings) {
|
||||
@@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => {
|
||||
|
||||
if (gitignoreExists) {
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
expect(gitignoreContent).toContain('.gemini/');
|
||||
expect(gitignoreContent).toContain('.qwen/');
|
||||
expect(gitignoreContent).toContain('gha-creds-*.json');
|
||||
}
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe('updateGitignore', () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
expect(content).toBe('.gemini/\ngha-creds-*.json\n');
|
||||
expect(content).toBe('.qwen/\ngha-creds-*.json\n');
|
||||
});
|
||||
|
||||
it('appends entries to existing .gitignore file', async () => {
|
||||
@@ -148,13 +148,13 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
expect(content).toBe(
|
||||
'# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n',
|
||||
'# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add duplicate entries', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
|
||||
const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n';
|
||||
await fs.writeFile(gitignorePath, existingContent);
|
||||
|
||||
await updateGitignore(scratchDir);
|
||||
@@ -166,7 +166,7 @@ describe('updateGitignore', () => {
|
||||
|
||||
it('adds only missing entries when some already exist', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = '.gemini/\nsome-other-file\n';
|
||||
const existingContent = '.qwen/\nsome-other-file\n';
|
||||
await fs.writeFile(gitignorePath, existingContent);
|
||||
|
||||
await updateGitignore(scratchDir);
|
||||
@@ -174,17 +174,17 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
// Should add only the missing gha-creds-*.json entry
|
||||
expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
|
||||
expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n');
|
||||
expect(content).toContain('gha-creds-*.json');
|
||||
// Should not duplicate .gemini/ entry
|
||||
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
|
||||
// Should not duplicate .qwen/ entry
|
||||
expect((content.match(/\.qwen\//g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not get confused by entries in comments or as substrings', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = [
|
||||
'# This is a comment mentioning .gemini/ folder',
|
||||
'my-app.gemini/config',
|
||||
'# This is a comment mentioning .qwen/ folder',
|
||||
'my-app.qwen/config',
|
||||
'# Another comment with gha-creds-*.json pattern',
|
||||
'some-other-gha-creds-file.json',
|
||||
'',
|
||||
@@ -196,7 +196,7 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
// Should add both entries since they don't actually exist as gitignore rules
|
||||
expect(content).toContain('.gemini/');
|
||||
expect(content).toContain('.qwen/');
|
||||
expect(content).toContain('gha-creds-*.json');
|
||||
|
||||
// Verify the entries were added (not just mentioned in comments)
|
||||
@@ -204,9 +204,9 @@ describe('updateGitignore', () => {
|
||||
.split('\n')
|
||||
.map((line) => line.split('#')[0].trim())
|
||||
.filter((line) => line);
|
||||
expect(lines).toContain('.gemini/');
|
||||
expect(lines).toContain('.qwen/');
|
||||
expect(lines).toContain('gha-creds-*.json');
|
||||
expect(lines).toContain('my-app.gemini/config');
|
||||
expect(lines).toContain('my-app.qwen/config');
|
||||
expect(lines).toContain('some-other-gha-creds-file.json');
|
||||
});
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const GITHUB_WORKFLOW_PATHS = [
|
||||
'gemini-dispatch/gemini-dispatch.yml',
|
||||
'gemini-assistant/gemini-invoke.yml',
|
||||
'issue-triage/gemini-triage.yml',
|
||||
'issue-triage/gemini-scheduled-triage.yml',
|
||||
'pr-review/gemini-review.yml',
|
||||
'qwen-dispatch/qwen-dispatch.yml',
|
||||
'qwen-assistant/qwen-invoke.yml',
|
||||
'issue-triage/qwen-triage.yml',
|
||||
'issue-triage/qwen-scheduled-triage.yml',
|
||||
'pr-review/qwen-review.yml',
|
||||
];
|
||||
|
||||
// Generate OS-specific commands to open the GitHub pages needed for setup.
|
||||
@@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Add Gemini CLI specific entries to .gitignore file
|
||||
// Add Qwen Code specific entries to .gitignore file
|
||||
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
|
||||
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
|
||||
const gitignoreEntries = ['.qwen/', 'gha-creds-*.json'];
|
||||
|
||||
const gitignorePath = path.join(gitRepoRoot, '.gitignore');
|
||||
try {
|
||||
@@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = {
|
||||
// Get the latest release tag from GitHub
|
||||
const proxy = context?.services?.config?.getProxy();
|
||||
const releaseTag = await getLatestGitHubRelease(proxy);
|
||||
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
|
||||
const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`;
|
||||
|
||||
// Create the .github/workflows directory to download the files into
|
||||
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
||||
@@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = {
|
||||
for (const workflow of GITHUB_WORKFLOW_PATHS) {
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
@@ -204,8 +204,9 @@ export const setupGithubCommand: SlashCommand = {
|
||||
toolName: 'run_shell_command',
|
||||
toolArgs: {
|
||||
description:
|
||||
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
|
||||
'Setting up GitHub Actions to triage issues and review PRs with Qwen.',
|
||||
command,
|
||||
is_background: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1307,7 +1307,7 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
|
||||
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
|
||||
@@ -707,15 +707,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
statusText = t('Accepting edits');
|
||||
}
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default
|
||||
}
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
@@ -829,9 +834,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ! Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
! Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ * Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
* Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
@@ -58,7 +58,7 @@ export const getLatestGitHubRelease = async (
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
|
||||
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
|
||||
const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
@@ -83,9 +83,12 @@ export const getLatestGitHubRelease = async (
|
||||
}
|
||||
return releaseTag;
|
||||
} catch (_error) {
|
||||
console.debug(`Failed to determine latest run-gemini-cli release:`, _error);
|
||||
console.debug(
|
||||
`Failed to determine latest qwen-code-action release:`,
|
||||
_error,
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to determine the latest run-gemini-cli release on GitHub.`,
|
||||
`Unable to determine the latest qwen-code-action release on GitHub.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"fast-uri": "^3.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
|
||||
@@ -58,6 +58,7 @@ export type {
|
||||
SubAgentStartEvent,
|
||||
SubAgentRoundEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentUsageEvent,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentFinishEvent,
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
|
||||
export type SubAgentEvent =
|
||||
| 'start'
|
||||
@@ -20,6 +20,7 @@ export type SubAgentEvent =
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'tool_waiting_approval'
|
||||
| 'usage_metadata'
|
||||
| 'finish'
|
||||
| 'error';
|
||||
|
||||
@@ -31,6 +32,7 @@ export enum SubAgentEventType {
|
||||
TOOL_CALL = 'tool_call',
|
||||
TOOL_RESULT = 'tool_result',
|
||||
TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
|
||||
USAGE_METADATA = 'usage_metadata',
|
||||
FINISH = 'finish',
|
||||
ERROR = 'error',
|
||||
}
|
||||
@@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentUsageEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
usage: GenerateContentResponseUsageMetadata;
|
||||
durationMs?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentToolCallEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
|
||||
@@ -50,6 +50,15 @@ describe('SubagentStatistics', () => {
|
||||
expect(summary.outputTokens).toBe(600);
|
||||
expect(summary.totalTokens).toBe(1800);
|
||||
});
|
||||
|
||||
it('should track thought and cached tokens', () => {
|
||||
stats.recordTokens(100, 50, 10, 5);
|
||||
|
||||
const summary = stats.getSummary();
|
||||
expect(summary.thoughtTokens).toBe(10);
|
||||
expect(summary.cachedTokens).toBe(5);
|
||||
expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool usage statistics', () => {
|
||||
@@ -93,14 +102,14 @@ describe('SubagentStatistics', () => {
|
||||
stats.start(baseTime);
|
||||
stats.setRounds(2);
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordTokens(1000, 500);
|
||||
stats.recordTokens(1000, 500, 20, 10);
|
||||
|
||||
const result = stats.formatCompact('Test task', baseTime + 5000);
|
||||
|
||||
expect(result).toContain('📋 Task Completed: Test task');
|
||||
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
|
||||
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
|
||||
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)');
|
||||
expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)');
|
||||
});
|
||||
|
||||
it('should handle zero tool calls', () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface SubagentStatsSummary {
|
||||
successRate: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thoughtTokens: number;
|
||||
cachedTokens: number;
|
||||
totalTokens: number;
|
||||
estimatedCost: number;
|
||||
toolUsage: ToolUsageStats[];
|
||||
@@ -36,6 +38,8 @@ export class SubagentStatistics {
|
||||
private failedToolCalls = 0;
|
||||
private inputTokens = 0;
|
||||
private outputTokens = 0;
|
||||
private thoughtTokens = 0;
|
||||
private cachedTokens = 0;
|
||||
private toolUsage = new Map<string, ToolUsageStats>();
|
||||
|
||||
start(now = Date.now()) {
|
||||
@@ -74,9 +78,16 @@ export class SubagentStatistics {
|
||||
this.toolUsage.set(name, tu);
|
||||
}
|
||||
|
||||
recordTokens(input: number, output: number) {
|
||||
recordTokens(
|
||||
input: number,
|
||||
output: number,
|
||||
thought: number = 0,
|
||||
cached: number = 0,
|
||||
) {
|
||||
this.inputTokens += Math.max(0, input || 0);
|
||||
this.outputTokens += Math.max(0, output || 0);
|
||||
this.thoughtTokens += Math.max(0, thought || 0);
|
||||
this.cachedTokens += Math.max(0, cached || 0);
|
||||
}
|
||||
|
||||
getSummary(now = Date.now()): SubagentStatsSummary {
|
||||
@@ -86,7 +97,11 @@ export class SubagentStatistics {
|
||||
totalToolCalls > 0
|
||||
? (this.successfulToolCalls / totalToolCalls) * 100
|
||||
: 0;
|
||||
const totalTokens = this.inputTokens + this.outputTokens;
|
||||
const totalTokens =
|
||||
this.inputTokens +
|
||||
this.outputTokens +
|
||||
this.thoughtTokens +
|
||||
this.cachedTokens;
|
||||
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
|
||||
return {
|
||||
rounds: this.rounds,
|
||||
@@ -97,6 +112,8 @@ export class SubagentStatistics {
|
||||
successRate,
|
||||
inputTokens: this.inputTokens,
|
||||
outputTokens: this.outputTokens,
|
||||
thoughtTokens: this.thoughtTokens,
|
||||
cachedTokens: this.cachedTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
toolUsage: Array.from(this.toolUsage.values()),
|
||||
@@ -116,8 +133,12 @@ export class SubagentStatistics {
|
||||
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
|
||||
];
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
const parts = [
|
||||
`in ${stats.inputTokens ?? 0}`,
|
||||
`out ${stats.outputTokens ?? 0}`,
|
||||
];
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -152,8 +173,12 @@ export class SubagentStatistics {
|
||||
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
|
||||
);
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
const parts = [
|
||||
`in ${stats.inputTokens ?? 0}`,
|
||||
`out ${stats.outputTokens ?? 0}`,
|
||||
];
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`,
|
||||
);
|
||||
}
|
||||
if (stats.toolUsage && stats.toolUsage.length) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentErrorEvent,
|
||||
SubAgentUsageEvent,
|
||||
} from './subagent-events.js';
|
||||
import {
|
||||
type SubAgentEventEmitter,
|
||||
@@ -369,6 +370,7 @@ export class SubAgentScope {
|
||||
},
|
||||
};
|
||||
|
||||
const roundStreamStart = Date.now();
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
this.modelConfig.model ||
|
||||
this.runtimeContext.getModel() ||
|
||||
@@ -439,10 +441,19 @@ export class SubAgentScope {
|
||||
if (lastUsage) {
|
||||
const inTok = Number(lastUsage.promptTokenCount || 0);
|
||||
const outTok = Number(lastUsage.candidatesTokenCount || 0);
|
||||
if (isFinite(inTok) || isFinite(outTok)) {
|
||||
const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0);
|
||||
const cachedTok = Number(lastUsage.cachedContentTokenCount || 0);
|
||||
if (
|
||||
isFinite(inTok) ||
|
||||
isFinite(outTok) ||
|
||||
isFinite(thoughtTok) ||
|
||||
isFinite(cachedTok)
|
||||
) {
|
||||
this.stats.recordTokens(
|
||||
isFinite(inTok) ? inTok : 0,
|
||||
isFinite(outTok) ? outTok : 0,
|
||||
isFinite(thoughtTok) ? thoughtTok : 0,
|
||||
isFinite(cachedTok) ? cachedTok : 0,
|
||||
);
|
||||
// mirror legacy fields for compatibility
|
||||
this.executionStats.inputTokens =
|
||||
@@ -453,11 +464,20 @@ export class SubAgentScope {
|
||||
(isFinite(outTok) ? outTok : 0);
|
||||
this.executionStats.totalTokens =
|
||||
(this.executionStats.inputTokens || 0) +
|
||||
(this.executionStats.outputTokens || 0);
|
||||
(this.executionStats.outputTokens || 0) +
|
||||
(isFinite(thoughtTok) ? thoughtTok : 0) +
|
||||
(isFinite(cachedTok) ? cachedTok : 0);
|
||||
this.executionStats.estimatedCost =
|
||||
(this.executionStats.inputTokens || 0) * 3e-5 +
|
||||
(this.executionStats.outputTokens || 0) * 6e-5;
|
||||
}
|
||||
this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
usage: lastUsage,
|
||||
durationMs: Date.now() - roundStreamStart,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentUsageEvent);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
|
||||
@@ -23,6 +23,12 @@ export type UiEvent =
|
||||
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
|
||||
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
|
||||
|
||||
export {
|
||||
EVENT_API_ERROR,
|
||||
EVENT_API_RESPONSE,
|
||||
EVENT_TOOL_CALL,
|
||||
} from './constants.js';
|
||||
|
||||
export interface ToolCallStats {
|
||||
count: number;
|
||||
success: number;
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
This file contains third-party software notices and license terms.
|
||||
|
||||
============================================================
|
||||
semver@7.7.2
|
||||
(git+https://github.com/npm/node-semver.git)
|
||||
|
||||
The ISC License
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
@modelcontextprotocol/sdk@1.15.1
|
||||
(git+https://github.com/modelcontextprotocol/typescript-sdk.git)
|
||||
@@ -2317,3 +2338,520 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
============================================================
|
||||
markdown-it@14.1.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
argparse@2.0.1
|
||||
(No repository found)
|
||||
|
||||
A. HISTORY OF THE SOFTWARE
|
||||
==========================
|
||||
|
||||
Python was created in the early 1990s by Guido van Rossum at Stichting
|
||||
Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands
|
||||
as a successor of a language called ABC. Guido remains Python's
|
||||
principal author, although it includes many contributions from others.
|
||||
|
||||
In 1995, Guido continued his work on Python at the Corporation for
|
||||
National Research Initiatives (CNRI, see http://www.cnri.reston.va.us)
|
||||
in Reston, Virginia where he released several versions of the
|
||||
software.
|
||||
|
||||
In May 2000, Guido and the Python core development team moved to
|
||||
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
|
||||
year, the PythonLabs team moved to Digital Creations, which became
|
||||
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
|
||||
https://www.python.org/psf/) was formed, a non-profit organization
|
||||
created specifically to own Python-related Intellectual Property.
|
||||
Zope Corporation was a sponsoring member of the PSF.
|
||||
|
||||
All Python releases are Open Source (see http://www.opensource.org for
|
||||
the Open Source Definition). Historically, most, but not all, Python
|
||||
releases have also been GPL-compatible; the table below summarizes
|
||||
the various releases.
|
||||
|
||||
Release Derived Year Owner GPL-
|
||||
from compatible? (1)
|
||||
|
||||
0.9.0 thru 1.2 1991-1995 CWI yes
|
||||
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
|
||||
1.6 1.5.2 2000 CNRI no
|
||||
2.0 1.6 2000 BeOpen.com no
|
||||
1.6.1 1.6 2001 CNRI yes (2)
|
||||
2.1 2.0+1.6.1 2001 PSF no
|
||||
2.0.1 2.0+1.6.1 2001 PSF yes
|
||||
2.1.1 2.1+2.0.1 2001 PSF yes
|
||||
2.1.2 2.1.1 2002 PSF yes
|
||||
2.1.3 2.1.2 2002 PSF yes
|
||||
2.2 and above 2.1.1 2001-now PSF yes
|
||||
|
||||
Footnotes:
|
||||
|
||||
(1) GPL-compatible doesn't mean that we're distributing Python under
|
||||
the GPL. All Python licenses, unlike the GPL, let you distribute
|
||||
a modified version without making your changes open source. The
|
||||
GPL-compatible licenses make it possible to combine Python with
|
||||
other software that is released under the GPL; the others don't.
|
||||
|
||||
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
|
||||
because its license has a choice of law clause. According to
|
||||
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
|
||||
is "not incompatible" with the GPL.
|
||||
|
||||
Thanks to the many outside volunteers who have worked under Guido's
|
||||
direction to make these releases possible.
|
||||
|
||||
|
||||
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
|
||||
===============================================================
|
||||
|
||||
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
|
||||
--------------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Python Software Foundation
|
||||
("PSF"), and the Individual or Organization ("Licensee") accessing and
|
||||
otherwise using this software ("Python") in source or binary form and
|
||||
its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, PSF hereby
|
||||
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
|
||||
analyze, test, perform and/or display publicly, prepare derivative works,
|
||||
distribute, and otherwise use Python alone or in any derivative version,
|
||||
provided, however, that PSF's License Agreement and PSF's notice of copyright,
|
||||
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
|
||||
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation;
|
||||
All Rights Reserved" are retained in Python alone or in any derivative version
|
||||
prepared by Licensee.
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python.
|
||||
|
||||
4. PSF is making Python available to Licensee on an "AS IS"
|
||||
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. Nothing in this License Agreement shall be deemed to create any
|
||||
relationship of agency, partnership, or joint venture between PSF and
|
||||
Licensee. This License Agreement does not grant permission to use PSF
|
||||
trademarks or trade name in a trademark sense to endorse or promote
|
||||
products or services of Licensee, or any third party.
|
||||
|
||||
8. By copying, installing or otherwise using Python, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
|
||||
|
||||
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
|
||||
-------------------------------------------
|
||||
|
||||
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
|
||||
|
||||
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
|
||||
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
|
||||
Individual or Organization ("Licensee") accessing and otherwise using
|
||||
this software in source or binary form and its associated
|
||||
documentation ("the Software").
|
||||
|
||||
2. Subject to the terms and conditions of this BeOpen Python License
|
||||
Agreement, BeOpen hereby grants Licensee a non-exclusive,
|
||||
royalty-free, world-wide license to reproduce, analyze, test, perform
|
||||
and/or display publicly, prepare derivative works, distribute, and
|
||||
otherwise use the Software alone or in any derivative version,
|
||||
provided, however, that the BeOpen Python License is retained in the
|
||||
Software, alone or in any derivative version prepared by Licensee.
|
||||
|
||||
3. BeOpen is making the Software available to Licensee on an "AS IS"
|
||||
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
|
||||
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
|
||||
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
|
||||
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
5. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
6. This License Agreement shall be governed by and interpreted in all
|
||||
respects by the law of the State of California, excluding conflict of
|
||||
law provisions. Nothing in this License Agreement shall be deemed to
|
||||
create any relationship of agency, partnership, or joint venture
|
||||
between BeOpen and Licensee. This License Agreement does not grant
|
||||
permission to use BeOpen trademarks or trade names in a trademark
|
||||
sense to endorse or promote products or services of Licensee, or any
|
||||
third party. As an exception, the "BeOpen Python" logos available at
|
||||
http://www.pythonlabs.com/logos.html may be used according to the
|
||||
permissions granted on that web page.
|
||||
|
||||
7. By copying, installing or otherwise using the software, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
|
||||
|
||||
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
|
||||
---------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Corporation for National
|
||||
Research Initiatives, having an office at 1895 Preston White Drive,
|
||||
Reston, VA 20191 ("CNRI"), and the Individual or Organization
|
||||
("Licensee") accessing and otherwise using Python 1.6.1 software in
|
||||
source or binary form and its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, CNRI
|
||||
hereby grants Licensee a nonexclusive, royalty-free, world-wide
|
||||
license to reproduce, analyze, test, perform and/or display publicly,
|
||||
prepare derivative works, distribute, and otherwise use Python 1.6.1
|
||||
alone or in any derivative version, provided, however, that CNRI's
|
||||
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
|
||||
1995-2001 Corporation for National Research Initiatives; All Rights
|
||||
Reserved" are retained in Python 1.6.1 alone or in any derivative
|
||||
version prepared by Licensee. Alternately, in lieu of CNRI's License
|
||||
Agreement, Licensee may substitute the following text (omitting the
|
||||
quotes): "Python 1.6.1 is made available subject to the terms and
|
||||
conditions in CNRI's License Agreement. This Agreement together with
|
||||
Python 1.6.1 may be located on the Internet using the following
|
||||
unique, persistent identifier (known as a handle): 1895.22/1013. This
|
||||
Agreement may also be obtained from a proxy server on the Internet
|
||||
using the following URL: http://hdl.handle.net/1895.22/1013".
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python 1.6.1 or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python 1.6.1.
|
||||
|
||||
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
|
||||
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. This License Agreement shall be governed by the federal
|
||||
intellectual property law of the United States, including without
|
||||
limitation the federal copyright law, and, to the extent such
|
||||
U.S. federal law does not apply, by the law of the Commonwealth of
|
||||
Virginia, excluding Virginia's conflict of law provisions.
|
||||
Notwithstanding the foregoing, with regard to derivative works based
|
||||
on Python 1.6.1 that incorporate non-separable material that was
|
||||
previously distributed under the GNU General Public License (GPL), the
|
||||
law of the Commonwealth of Virginia shall govern this License
|
||||
Agreement only as to issues arising under or with respect to
|
||||
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
|
||||
License Agreement shall be deemed to create any relationship of
|
||||
agency, partnership, or joint venture between CNRI and Licensee. This
|
||||
License Agreement does not grant permission to use CNRI trademarks or
|
||||
trade name in a trademark sense to endorse or promote products or
|
||||
services of Licensee, or any third party.
|
||||
|
||||
8. By clicking on the "ACCEPT" button where indicated, or by copying,
|
||||
installing or otherwise using Python 1.6.1, Licensee agrees to be
|
||||
bound by the terms and conditions of this License Agreement.
|
||||
|
||||
ACCEPT
|
||||
|
||||
|
||||
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
|
||||
--------------------------------------------------
|
||||
|
||||
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
|
||||
The Netherlands. All rights reserved.
|
||||
|
||||
Permission to use, copy, modify, and distribute this software and its
|
||||
documentation for any purpose and without fee is hereby granted,
|
||||
provided that the above copyright notice appear in all copies and that
|
||||
both that copyright notice and this permission notice appear in
|
||||
supporting documentation, and that the name of Stichting Mathematisch
|
||||
Centrum or CWI not be used in advertising or publicity pertaining to
|
||||
distribution of the software without specific, written prior
|
||||
permission.
|
||||
|
||||
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
|
||||
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
entities@4.5.0
|
||||
(git://github.com/fb55/entities.git)
|
||||
|
||||
Copyright (c) Felix Böhm
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS,
|
||||
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
============================================================
|
||||
linkify-it@5.0.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2015 Vitaly Puzrin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
uc.micro@2.1.0
|
||||
(No repository found)
|
||||
|
||||
Copyright Mathias Bynens <https://mathiasbynens.be/>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
mdurl@2.0.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
.parse() is based on Joyent's node.js `url` code:
|
||||
|
||||
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
punycode.js@2.3.1
|
||||
(https://github.com/mathiasbynens/punycode.js.git)
|
||||
|
||||
Copyright Mathias Bynens <https://mathiasbynens.be/>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
react@19.1.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
react-dom@19.1.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
scheduler@0.26.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,63 @@ To use this extension, you'll need:
|
||||
- VS Code version 1.101.0 or newer
|
||||
- Qwen Code (installed separately) running within the VS Code integrated terminal
|
||||
|
||||
# Development and Debugging
|
||||
|
||||
To debug and develop this extension locally:
|
||||
|
||||
1. **Clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QwenLM/qwen-code.git
|
||||
cd qwen-code
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or if using pnpm
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Start debugging**
|
||||
|
||||
```bash
|
||||
code . # Open the project root in VS Code
|
||||
```
|
||||
- Open the `packages/vscode-ide-companion/src/extension.ts` file
|
||||
- Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`)
|
||||
- Select **"Launch Companion VS Code Extension"** from the debug dropdown
|
||||
- Press `F5` to launch Extension Development Host
|
||||
|
||||
4. **Make changes and reload**
|
||||
- Edit the source code in the original VS Code window
|
||||
- To see your changes, reload the Extension Development Host window by:
|
||||
- Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS)
|
||||
- Or clicking the "Reload" button in the debug toolbar
|
||||
|
||||
5. **View logs and debug output**
|
||||
- Open the Debug Console in the original VS Code window to see extension logs
|
||||
- In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs
|
||||
|
||||
## Build for Production
|
||||
|
||||
To build the extension for distribution:
|
||||
|
||||
```bash
|
||||
npm run compile
|
||||
# or
|
||||
pnpm run compile
|
||||
```
|
||||
|
||||
To package the extension as a VSIX file:
|
||||
|
||||
```bash
|
||||
npx vsce package
|
||||
# or
|
||||
pnpm vsce package
|
||||
```
|
||||
|
||||
# Terms of Service and Privacy Notice
|
||||
|
||||
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).
|
||||
|
||||
@@ -31,8 +31,69 @@ const esbuildProblemMatcherPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const cssInjectPlugin = {
|
||||
name: 'css-inject',
|
||||
setup(build) {
|
||||
// Handle CSS files
|
||||
build.onLoad({ filter: /\.css$/ }, async (args) => {
|
||||
const fs = await import('fs');
|
||||
const postcss = (await import('postcss')).default;
|
||||
const tailwindcss = (await import('tailwindcss')).default;
|
||||
const autoprefixer = (await import('autoprefixer')).default;
|
||||
|
||||
let css = await fs.promises.readFile(args.path, 'utf8');
|
||||
|
||||
// For styles.css, we need to resolve @import statements
|
||||
if (args.path.endsWith('styles.css')) {
|
||||
// Read all imported CSS files and inline them
|
||||
const importRegex = /@import\s+'([^']+)';/g;
|
||||
let match;
|
||||
const basePath = args.path.substring(0, args.path.lastIndexOf('/'));
|
||||
while ((match = importRegex.exec(css)) !== null) {
|
||||
const importPath = match[1];
|
||||
// Resolve relative paths correctly
|
||||
let fullPath;
|
||||
if (importPath.startsWith('./')) {
|
||||
fullPath = basePath + importPath.substring(1);
|
||||
} else if (importPath.startsWith('../')) {
|
||||
fullPath = basePath + '/' + importPath;
|
||||
} else {
|
||||
fullPath = basePath + '/' + importPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCss = await fs.promises.readFile(fullPath, 'utf8');
|
||||
css = css.replace(match[0], importedCss);
|
||||
} catch (err) {
|
||||
console.warn(`Could not import ${fullPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process with PostCSS (Tailwind + Autoprefixer)
|
||||
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
|
||||
from: args.path,
|
||||
to: args.path,
|
||||
});
|
||||
|
||||
return {
|
||||
contents: `
|
||||
const style = document.createElement('style');
|
||||
style.textContent = ${JSON.stringify(result.css)};
|
||||
document.head.appendChild(style);
|
||||
`,
|
||||
loader: 'js',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context({
|
||||
// Build extension
|
||||
const extensionCtx = await esbuild.context({
|
||||
entryPoints: ['src/extension.ts'],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
@@ -55,11 +116,30 @@ async function main() {
|
||||
],
|
||||
loader: { '.node': 'file' },
|
||||
});
|
||||
|
||||
// Build webview
|
||||
const webviewCtx = await esbuild.context({
|
||||
entryPoints: ['src/webview/index.tsx'],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
sourcesContent: false,
|
||||
platform: 'browser',
|
||||
outfile: 'dist/webview.js',
|
||||
logLevel: 'silent',
|
||||
plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin],
|
||||
jsx: 'automatic', // Use new JSX transform (React 17+)
|
||||
define: {
|
||||
'process.env.NODE_ENV': production ? '"production"' : '"development"',
|
||||
},
|
||||
});
|
||||
|
||||
if (watch) {
|
||||
await ctx.watch();
|
||||
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
|
||||
} else {
|
||||
await ctx.rebuild();
|
||||
await ctx.dispose();
|
||||
await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]);
|
||||
await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,44 @@
|
||||
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'react-hooks': reactHooks,
|
||||
import: importPlugin,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
@@ -30,6 +54,17 @@ export default [
|
||||
format: ['camelCase', 'PascalCase'],
|
||||
},
|
||||
],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
// Restrict deep imports but allow known-safe exceptions used by the webview
|
||||
// - react-dom/client: required for React 18's createRoot API
|
||||
// - ./styles/**: local CSS modules loaded by the webview
|
||||
'import/no-internal-modules': [
|
||||
'error',
|
||||
{
|
||||
allow: ['react-dom/client', './styles/**'],
|
||||
},
|
||||
],
|
||||
|
||||
curly: 'warn',
|
||||
eqeqeq: 'warn',
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
{
|
||||
"command": "qwen-code.showNotices",
|
||||
"title": "Qwen Code: View Third-Party Notices"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.openChat",
|
||||
"title": "Qwen Code: Open",
|
||||
"icon": "./assets/icon.png"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"title": "Qwen Code: Login"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -65,6 +74,10 @@
|
||||
{
|
||||
"command": "qwen.diff.cancel",
|
||||
"when": "qwen.diff.isVisible"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"when": "false"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
@@ -77,6 +90,10 @@
|
||||
"command": "qwen.diff.cancel",
|
||||
"when": "qwen.diff.isVisible",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.openChat",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -115,21 +132,33 @@
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "20.x",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"npm-run-all2": "^8.0.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "^7.7.2",
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
13
packages/vscode-ide-companion/postcss.config.js
Normal file
13
packages/vscode-ide-companion/postcss.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
58
packages/vscode-ide-companion/src/cli/cliContextManager.ts
Normal file
58
packages/vscode-ide-companion/src/cli/cliContextManager.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CliFeatureFlags, CliVersionInfo } from './cliVersionManager.js';
|
||||
|
||||
export class CliContextManager {
|
||||
private static instance: CliContextManager;
|
||||
private currentVersionInfo: CliVersionInfo | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): CliContextManager {
|
||||
if (!CliContextManager.instance) {
|
||||
CliContextManager.instance = new CliContextManager();
|
||||
}
|
||||
return CliContextManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current CLI version information
|
||||
*
|
||||
* @param versionInfo - CLI version information
|
||||
*/
|
||||
setCurrentVersionInfo(versionInfo: CliVersionInfo): void {
|
||||
this.currentVersionInfo = versionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current CLI feature flags
|
||||
*
|
||||
* @returns Current CLI feature flags or default flags if not set
|
||||
*/
|
||||
getCurrentFeatures(): CliFeatureFlags {
|
||||
if (this.currentVersionInfo) {
|
||||
return this.currentVersionInfo.features;
|
||||
}
|
||||
|
||||
// Return default feature flags (all disabled)
|
||||
return {
|
||||
supportsSessionList: false,
|
||||
supportsSessionLoad: false,
|
||||
};
|
||||
}
|
||||
|
||||
supportsSessionList(): boolean {
|
||||
return this.getCurrentFeatures().supportsSessionList;
|
||||
}
|
||||
|
||||
supportsSessionLoad(): boolean {
|
||||
return this.getCurrentFeatures().supportsSessionLoad;
|
||||
}
|
||||
}
|
||||
215
packages/vscode-ide-companion/src/cli/cliDetector.ts
Normal file
215
packages/vscode-ide-companion/src/cli/cliDetector.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface CliDetectionResult {
|
||||
isInstalled: boolean;
|
||||
cliPath?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if Qwen Code CLI is installed and accessible
|
||||
*/
|
||||
export class CliDetector {
|
||||
private static cachedResult: CliDetectionResult | null = null;
|
||||
private static lastCheckTime: number = 0;
|
||||
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Checks if the Qwen Code CLI is installed
|
||||
* @param forceRefresh - Force a new check, ignoring cache
|
||||
* @returns Detection result with installation status and details
|
||||
*/
|
||||
static async detectQwenCli(
|
||||
forceRefresh = false,
|
||||
): Promise<CliDetectionResult> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached result if available and not expired
|
||||
if (
|
||||
!forceRefresh &&
|
||||
this.cachedResult &&
|
||||
now - this.lastCheckTime < this.CACHE_DURATION_MS
|
||||
) {
|
||||
console.log('[CliDetector] Returning cached result');
|
||||
return this.cachedResult;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[CliDetector] Starting CLI detection, current PATH:',
|
||||
process.env.PATH,
|
||||
);
|
||||
|
||||
try {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const whichCommand = isWindows ? 'where' : 'which';
|
||||
|
||||
// Check if qwen command exists
|
||||
try {
|
||||
// Use NVM environment for consistent detection
|
||||
// Fallback chain: default alias -> node alias -> current version
|
||||
const detectionCommand =
|
||||
process.platform === 'win32'
|
||||
? `${whichCommand} qwen`
|
||||
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); which qwen';
|
||||
|
||||
console.log(
|
||||
'[CliDetector] Detecting CLI with command:',
|
||||
detectionCommand,
|
||||
);
|
||||
|
||||
const { stdout } = await execAsync(detectionCommand, {
|
||||
timeout: 5000,
|
||||
shell: '/bin/bash',
|
||||
});
|
||||
// The output may contain multiple lines, with NVM activation messages
|
||||
// We want the last line which should be the actual path
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
const cliPath = lines[lines.length - 1];
|
||||
|
||||
console.log('[CliDetector] Found CLI at:', cliPath);
|
||||
|
||||
// Try to get version
|
||||
let version: string | undefined;
|
||||
try {
|
||||
// Use NVM environment for version check
|
||||
// Fallback chain: default alias -> node alias -> current version
|
||||
// Also ensure we use the correct Node.js version that matches the CLI installation
|
||||
const versionCommand =
|
||||
process.platform === 'win32'
|
||||
? 'qwen --version'
|
||||
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); qwen --version';
|
||||
|
||||
console.log(
|
||||
'[CliDetector] Getting version with command:',
|
||||
versionCommand,
|
||||
);
|
||||
|
||||
const { stdout: versionOutput } = await execAsync(versionCommand, {
|
||||
timeout: 5000,
|
||||
shell: '/bin/bash',
|
||||
});
|
||||
// The output may contain multiple lines, with NVM activation messages
|
||||
// We want the last line which should be the actual version
|
||||
const versionLines = versionOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
version = versionLines[versionLines.length - 1];
|
||||
console.log('[CliDetector] CLI version:', version);
|
||||
} catch (versionError) {
|
||||
console.log('[CliDetector] Failed to get CLI version:', versionError);
|
||||
// Version check failed, but CLI is installed
|
||||
}
|
||||
|
||||
this.cachedResult = {
|
||||
isInstalled: true,
|
||||
cliPath,
|
||||
version,
|
||||
};
|
||||
this.lastCheckTime = now;
|
||||
return this.cachedResult;
|
||||
} catch (detectionError) {
|
||||
console.log('[CliDetector] CLI not found, error:', detectionError);
|
||||
// CLI not found
|
||||
let error = `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`;
|
||||
|
||||
// Provide specific guidance for permission errors
|
||||
if (detectionError instanceof Error) {
|
||||
const errorMessage = detectionError.message;
|
||||
if (
|
||||
errorMessage.includes('EACCES') ||
|
||||
errorMessage.includes('Permission denied')
|
||||
) {
|
||||
error += `\n\nThis may be due to permission issues. Possible solutions:
|
||||
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
|
||||
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
|
||||
\n3. Use nvm for Node.js version management to avoid permission issues
|
||||
\n4. Check your PATH environment variable includes npm's global bin directory`;
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedResult = {
|
||||
isInstalled: false,
|
||||
error,
|
||||
};
|
||||
this.lastCheckTime = now;
|
||||
return this.cachedResult;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CliDetector] General detection error:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
let userFriendlyError = `Failed to detect Qwen Code CLI: ${errorMessage}`;
|
||||
|
||||
// Provide specific guidance for permission errors
|
||||
if (
|
||||
errorMessage.includes('EACCES') ||
|
||||
errorMessage.includes('Permission denied')
|
||||
) {
|
||||
userFriendlyError += `\n\nThis may be due to permission issues. Possible solutions:
|
||||
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
|
||||
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
|
||||
\n3. Use nvm for Node.js version management to avoid permission issues
|
||||
\n4. Check your PATH environment variable includes npm's global bin directory`;
|
||||
}
|
||||
|
||||
this.cachedResult = {
|
||||
isInstalled: false,
|
||||
error: userFriendlyError,
|
||||
};
|
||||
this.lastCheckTime = now;
|
||||
return this.cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached detection result
|
||||
*/
|
||||
static clearCache(): void {
|
||||
this.cachedResult = null;
|
||||
this.lastCheckTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets installation instructions based on the platform
|
||||
*/
|
||||
static getInstallationInstructions(): {
|
||||
title: string;
|
||||
steps: string[];
|
||||
documentationUrl: string;
|
||||
} {
|
||||
return {
|
||||
title: 'Qwen Code CLI is not installed',
|
||||
steps: [
|
||||
'Install via npm:',
|
||||
' npm install -g @qwen-code/qwen-code@latest',
|
||||
'',
|
||||
'If you are using nvm (automatically handled by the plugin):',
|
||||
' The plugin will automatically use your default nvm version',
|
||||
'',
|
||||
'Or install from source:',
|
||||
' git clone https://github.com/QwenLM/qwen-code.git',
|
||||
' cd qwen-code',
|
||||
' npm install',
|
||||
' npm install -g .',
|
||||
'',
|
||||
'After installation, reload VS Code or restart the extension.',
|
||||
],
|
||||
documentationUrl: 'https://github.com/QwenLM/qwen-code#installation',
|
||||
};
|
||||
}
|
||||
}
|
||||
225
packages/vscode-ide-companion/src/cli/cliInstaller.ts
Normal file
225
packages/vscode-ide-companion/src/cli/cliInstaller.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { CliDetector } from './cliDetector.js';
|
||||
|
||||
/**
|
||||
* CLI Detection and Installation Handler
|
||||
* Responsible for detecting, installing, and prompting for Qwen CLI
|
||||
*/
|
||||
export class CliInstaller {
|
||||
/**
|
||||
* Check CLI installation status and send results to WebView
|
||||
* @param sendToWebView Callback function to send messages to WebView
|
||||
*/
|
||||
static async checkInstallation(
|
||||
sendToWebView: (message: unknown) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await CliDetector.detectQwenCli();
|
||||
|
||||
sendToWebView({
|
||||
type: 'cliDetectionResult',
|
||||
data: {
|
||||
isInstalled: result.isInstalled,
|
||||
cliPath: result.cliPath,
|
||||
version: result.version,
|
||||
error: result.error,
|
||||
installInstructions: result.isInstalled
|
||||
? undefined
|
||||
: CliDetector.getInstallationInstructions(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.isInstalled) {
|
||||
console.log('[CliInstaller] Qwen CLI not detected:', result.error);
|
||||
} else {
|
||||
console.log(
|
||||
'[CliInstaller] Qwen CLI detected:',
|
||||
result.cliPath,
|
||||
result.version,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CliInstaller] CLI detection error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to install CLI
|
||||
* Display warning message with installation options
|
||||
*/
|
||||
static async promptInstallation(): Promise<void> {
|
||||
const selection = await vscode.window.showWarningMessage(
|
||||
'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.',
|
||||
'Install Now',
|
||||
'View Documentation',
|
||||
'Remind Me Later',
|
||||
);
|
||||
|
||||
if (selection === 'Install Now') {
|
||||
await this.install();
|
||||
} else if (selection === 'View Documentation') {
|
||||
vscode.env.openExternal(
|
||||
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Qwen CLI
|
||||
* Install global CLI package via npm
|
||||
*/
|
||||
static async install(): Promise<void> {
|
||||
try {
|
||||
// Show progress notification
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Installing Qwen Code CLI',
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
progress.report({
|
||||
message: 'Running: npm install -g @qwen-code/qwen-code@latest',
|
||||
});
|
||||
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
try {
|
||||
// Use NVM environment to ensure we get the same Node.js version
|
||||
// as when they run 'node -v' in terminal
|
||||
// Fallback chain: default alias -> node alias -> current version
|
||||
const installCommand =
|
||||
process.platform === 'win32'
|
||||
? 'npm install -g @qwen-code/qwen-code@latest'
|
||||
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); npm install -g @qwen-code/qwen-code@latest';
|
||||
|
||||
console.log(
|
||||
'[CliInstaller] Installing with command:',
|
||||
installCommand,
|
||||
);
|
||||
console.log(
|
||||
'[CliInstaller] Current process PATH:',
|
||||
process.env['PATH'],
|
||||
);
|
||||
|
||||
// Also log Node.js version being used by VS Code
|
||||
console.log(
|
||||
'[CliInstaller] VS Code Node.js version:',
|
||||
process.version,
|
||||
);
|
||||
console.log(
|
||||
'[CliInstaller] VS Code Node.js execPath:',
|
||||
process.execPath,
|
||||
);
|
||||
|
||||
const { stdout, stderr } = await execAsync(
|
||||
installCommand,
|
||||
{
|
||||
timeout: 120000,
|
||||
shell: '/bin/bash',
|
||||
}, // 2 minutes timeout
|
||||
);
|
||||
|
||||
console.log('[CliInstaller] Installation output:', stdout);
|
||||
if (stderr) {
|
||||
console.warn('[CliInstaller] Installation stderr:', stderr);
|
||||
}
|
||||
|
||||
// Clear cache and recheck
|
||||
CliDetector.clearCache();
|
||||
const detection = await CliDetector.detectQwenCli();
|
||||
|
||||
if (detection.isInstalled) {
|
||||
vscode.window
|
||||
.showInformationMessage(
|
||||
`✅ Qwen Code CLI installed successfully! Version: ${detection.version}`,
|
||||
'Reload Window',
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === 'Reload Window') {
|
||||
vscode.commands.executeCommand(
|
||||
'workbench.action.reloadWindow',
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'Installation completed but CLI still not detected',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error('[CliInstaller] Installation failed:', errorMessage);
|
||||
console.error('[CliInstaller] Error stack:', error);
|
||||
|
||||
// Provide specific guidance for permission errors
|
||||
let userFriendlyMessage = `Failed to install Qwen Code CLI: ${errorMessage}`;
|
||||
|
||||
if (
|
||||
errorMessage.includes('EACCES') ||
|
||||
errorMessage.includes('Permission denied')
|
||||
) {
|
||||
userFriendlyMessage += `\n\nThis is likely due to permission issues. Possible solutions:
|
||||
\n1. Reinstall without sudo: npm install -g @qwen-code/qwen-code@latest
|
||||
\n2. Fix npm permissions: sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}
|
||||
\n3. Use nvm for Node.js version management to avoid permission issues
|
||||
\n4. Configure npm to use a different directory: npm config set prefix ~/.npm-global`;
|
||||
}
|
||||
|
||||
vscode.window
|
||||
.showErrorMessage(
|
||||
userFriendlyMessage,
|
||||
'Try Manual Installation',
|
||||
'View Documentation',
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === 'Try Manual Installation') {
|
||||
const terminal = vscode.window.createTerminal(
|
||||
'Qwen Code Installation',
|
||||
);
|
||||
terminal.show();
|
||||
|
||||
// Provide different installation commands based on error type
|
||||
if (
|
||||
errorMessage.includes('EACCES') ||
|
||||
errorMessage.includes('Permission denied')
|
||||
) {
|
||||
terminal.sendText('# Try installing without sudo:');
|
||||
terminal.sendText(
|
||||
'npm install -g @qwen-code/qwen-code@latest',
|
||||
);
|
||||
terminal.sendText('');
|
||||
terminal.sendText('# Or fix npm permissions:');
|
||||
terminal.sendText(
|
||||
'sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}',
|
||||
);
|
||||
} else {
|
||||
terminal.sendText(
|
||||
'npm install -g @qwen-code/qwen-code@latest',
|
||||
);
|
||||
}
|
||||
} else if (selection === 'View Documentation') {
|
||||
vscode.env.openExternal(
|
||||
vscode.Uri.parse(
|
||||
'https://github.com/QwenLM/qwen-code#installation',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[CliInstaller] Install CLI error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
packages/vscode-ide-companion/src/cli/cliPathDetector.ts
Normal file
128
packages/vscode-ide-companion/src/cli/cliPathDetector.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { statSync } from 'fs';
|
||||
|
||||
export interface CliPathDetectionResult {
|
||||
path: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the correct Node.js executable path for a given CLI installation
|
||||
* Handles various Node.js version managers (nvm, n, manual installations)
|
||||
*
|
||||
* @param cliPath - Path to the CLI executable
|
||||
* @returns Path to the Node.js executable, or null if not found
|
||||
*/
|
||||
export function determineNodePathForCli(
|
||||
cliPath: string,
|
||||
): CliPathDetectionResult {
|
||||
// Common patterns for Node.js installations
|
||||
const nodePathPatterns = [
|
||||
// NVM pattern: /Users/user/.nvm/versions/node/vXX.XX.X/bin/qwen -> /Users/user/.nvm/versions/node/vXX.XX.X/bin/node
|
||||
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
|
||||
|
||||
// N pattern: /Users/user/n/bin/qwen -> /Users/user/n/bin/node
|
||||
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
|
||||
|
||||
// Manual installation pattern: /usr/local/bin/qwen -> /usr/local/bin/node
|
||||
cliPath.replace(/\/qwen$/, '/node'),
|
||||
|
||||
// Alternative pattern: /opt/nodejs/bin/qwen -> /opt/nodejs/bin/node
|
||||
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
|
||||
];
|
||||
|
||||
// Check each pattern
|
||||
for (const nodePath of nodePathPatterns) {
|
||||
try {
|
||||
const stats = statSync(nodePath);
|
||||
if (stats.isFile()) {
|
||||
// Verify it's executable
|
||||
if (stats.mode & 0o111) {
|
||||
console.log(`[CLI] Found Node.js executable for CLI at: ${nodePath}`);
|
||||
return { path: nodePath };
|
||||
} else {
|
||||
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Differentiate between error types
|
||||
if (error instanceof Error) {
|
||||
if ('code' in error && error.code === 'EACCES') {
|
||||
console.log(`[CLI] Permission denied accessing ${nodePath}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
|
||||
};
|
||||
} else if ('code' in error && error.code === 'ENOENT') {
|
||||
// File not found, continue to next pattern
|
||||
continue;
|
||||
} else {
|
||||
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find node in the same directory as the CLI
|
||||
const cliDir = cliPath.substring(0, cliPath.lastIndexOf('/'));
|
||||
const potentialNodePaths = [`${cliDir}/node`, `${cliDir}/bin/node`];
|
||||
|
||||
for (const nodePath of potentialNodePaths) {
|
||||
try {
|
||||
const stats = statSync(nodePath);
|
||||
if (stats.isFile()) {
|
||||
if (stats.mode & 0o111) {
|
||||
console.log(
|
||||
`[CLI] Found Node.js executable in CLI directory at: ${nodePath}`,
|
||||
);
|
||||
return { path: nodePath };
|
||||
} else {
|
||||
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Differentiate between error types
|
||||
if (error instanceof Error) {
|
||||
if ('code' in error && error.code === 'EACCES') {
|
||||
console.log(`[CLI] Permission denied accessing ${nodePath}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
|
||||
};
|
||||
} else if ('code' in error && error.code === 'ENOENT') {
|
||||
// File not found, continue
|
||||
continue;
|
||||
} else {
|
||||
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CLI] Could not determine Node.js path for CLI: ${cliPath}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Could not find Node.js executable for CLI at ${cliPath}. Please verify the CLI installation.`,
|
||||
};
|
||||
}
|
||||
191
packages/vscode-ide-companion/src/cli/cliVersionManager.ts
Normal file
191
packages/vscode-ide-companion/src/cli/cliVersionManager.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import semver from 'semver';
|
||||
import { CliDetector, type CliDetectionResult } from './cliDetector.js';
|
||||
|
||||
export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.4.0';
|
||||
|
||||
export interface CliFeatureFlags {
|
||||
supportsSessionList: boolean;
|
||||
supportsSessionLoad: boolean;
|
||||
}
|
||||
|
||||
export interface CliVersionInfo {
|
||||
version: string | undefined;
|
||||
isSupported: boolean;
|
||||
features: CliFeatureFlags;
|
||||
detectionResult: CliDetectionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI Version Manager
|
||||
*
|
||||
* Manages CLI version detection and feature availability based on version
|
||||
*/
|
||||
export class CliVersionManager {
|
||||
private static instance: CliVersionManager;
|
||||
private cachedVersionInfo: CliVersionInfo | null = null;
|
||||
private lastCheckTime: number = 0;
|
||||
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): CliVersionManager {
|
||||
if (!CliVersionManager.instance) {
|
||||
CliVersionManager.instance = new CliVersionManager();
|
||||
}
|
||||
return CliVersionManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI version meets minimum requirements
|
||||
*
|
||||
* @param version - Version string to check
|
||||
* @param minVersion - Minimum required version
|
||||
* @returns Whether version meets requirements
|
||||
*/
|
||||
private isVersionSupported(
|
||||
version: string | undefined,
|
||||
minVersion: string,
|
||||
): boolean {
|
||||
if (!version) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use semver for robust comparison (handles v-prefix, pre-release, etc.)
|
||||
const v = semver.valid(version) ?? semver.coerce(version)?.version ?? null;
|
||||
const min =
|
||||
semver.valid(minVersion) ?? semver.coerce(minVersion)?.version ?? null;
|
||||
|
||||
if (!v || !min) {
|
||||
console.warn(
|
||||
`[CliVersionManager] Invalid semver: version=${version}, min=${minVersion}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
console.log(`[CliVersionManager] Version ${v} meets requirements: ${min}`);
|
||||
return semver.gte(v, min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature flags based on CLI version
|
||||
*
|
||||
* @param version - CLI version string
|
||||
* @returns Feature flags
|
||||
*/
|
||||
private getFeatureFlags(version: string | undefined): CliFeatureFlags {
|
||||
const isSupportedVersion = this.isVersionSupported(
|
||||
version,
|
||||
MIN_CLI_VERSION_FOR_SESSION_METHODS,
|
||||
);
|
||||
|
||||
return {
|
||||
supportsSessionList: isSupportedVersion,
|
||||
supportsSessionLoad: isSupportedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect CLI version and features
|
||||
*
|
||||
* @param forceRefresh - Force a new check, ignoring cache
|
||||
* @returns CLI version information
|
||||
*/
|
||||
async detectCliVersion(forceRefresh = false): Promise<CliVersionInfo> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached result if available and not expired
|
||||
if (
|
||||
!forceRefresh &&
|
||||
this.cachedVersionInfo &&
|
||||
now - this.lastCheckTime < CliVersionManager.CACHE_DURATION_MS
|
||||
) {
|
||||
console.log('[CliVersionManager] Returning cached version info');
|
||||
return this.cachedVersionInfo;
|
||||
}
|
||||
|
||||
console.log('[CliVersionManager] Detecting CLI version...');
|
||||
|
||||
try {
|
||||
// Detect CLI installation
|
||||
const detectionResult = await CliDetector.detectQwenCli(forceRefresh);
|
||||
|
||||
const versionInfo: CliVersionInfo = {
|
||||
version: detectionResult.version,
|
||||
isSupported: this.isVersionSupported(
|
||||
detectionResult.version,
|
||||
MIN_CLI_VERSION_FOR_SESSION_METHODS,
|
||||
),
|
||||
features: this.getFeatureFlags(detectionResult.version),
|
||||
detectionResult,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
this.cachedVersionInfo = versionInfo;
|
||||
this.lastCheckTime = now;
|
||||
|
||||
console.log(
|
||||
'[CliVersionManager] CLI version detection result:',
|
||||
versionInfo,
|
||||
);
|
||||
|
||||
return versionInfo;
|
||||
} catch (error) {
|
||||
console.error('[CliVersionManager] Failed to detect CLI version:', error);
|
||||
|
||||
// Return fallback result
|
||||
const fallbackResult: CliVersionInfo = {
|
||||
version: undefined,
|
||||
isSupported: false,
|
||||
features: {
|
||||
supportsSessionList: false,
|
||||
supportsSessionLoad: false,
|
||||
},
|
||||
detectionResult: {
|
||||
isInstalled: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
|
||||
return fallbackResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached version information
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedVersionInfo = null;
|
||||
this.lastCheckTime = 0;
|
||||
CliDetector.clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI supports session/list method
|
||||
*
|
||||
* @param forceRefresh - Force a new check, ignoring cache
|
||||
* @returns Whether session/list is supported
|
||||
*/
|
||||
async supportsSessionList(forceRefresh = false): Promise<boolean> {
|
||||
const versionInfo = await this.detectCliVersion(forceRefresh);
|
||||
return versionInfo.features.supportsSessionList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI supports session/load method
|
||||
*
|
||||
* @param forceRefresh - Force a new check, ignoring cache
|
||||
* @returns Whether session/load is supported
|
||||
*/
|
||||
async supportsSessionLoad(forceRefresh = false): Promise<boolean> {
|
||||
const versionInfo = await this.detectCliVersion(forceRefresh);
|
||||
return versionInfo.features.supportsSessionLoad;
|
||||
}
|
||||
}
|
||||
80
packages/vscode-ide-companion/src/commands/index.ts
Normal file
80
packages/vscode-ide-companion/src/commands/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { DiffManager } from '../diff-manager.js';
|
||||
import type { WebViewProvider } from '../webview/WebViewProvider.js';
|
||||
|
||||
type Logger = (message: string) => void;
|
||||
|
||||
export const runQwenCodeCommand = 'qwen-code.runQwenCode';
|
||||
export const showDiffCommand = 'qwenCode.showDiff';
|
||||
export const openChatCommand = 'qwen-code.openChat';
|
||||
export const openNewChatTabCommand = 'qwenCode.openNewChatTab';
|
||||
export const loginCommand = 'qwen-code.login';
|
||||
|
||||
export function registerNewCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
log: Logger,
|
||||
diffManager: DiffManager,
|
||||
getWebViewProviders: () => WebViewProvider[],
|
||||
createWebViewProvider: () => WebViewProvider,
|
||||
): void {
|
||||
const disposables: vscode.Disposable[] = [];
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(openChatCommand, async () => {
|
||||
const providers = getWebViewProviders();
|
||||
if (providers.length > 0) {
|
||||
await providers[providers.length - 1].show();
|
||||
} else {
|
||||
const provider = createWebViewProvider();
|
||||
await provider.show();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(
|
||||
showDiffCommand,
|
||||
async (args: { path: string; oldText: string; newText: string }) => {
|
||||
try {
|
||||
let absolutePath = args.path;
|
||||
if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
absolutePath = vscode.Uri.joinPath(
|
||||
workspaceFolder.uri,
|
||||
args.path,
|
||||
).fsPath;
|
||||
}
|
||||
}
|
||||
log(`[Command] Showing diff for ${absolutePath}`);
|
||||
await diffManager.showDiff(absolutePath, args.oldText, args.newText);
|
||||
} catch (error) {
|
||||
log(`[Command] Error showing diff: ${error}`);
|
||||
vscode.window.showErrorMessage(`Failed to show diff: ${error}`);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(openNewChatTabCommand, async () => {
|
||||
const provider = createWebViewProvider();
|
||||
// Session restoration is now disabled by default, so no need to suppress it
|
||||
await provider.show();
|
||||
}),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(loginCommand, async () => {
|
||||
const providers = getWebViewProviders();
|
||||
if (providers.length > 0) {
|
||||
await providers[providers.length - 1].forceReLogin();
|
||||
} else {
|
||||
vscode.window.showInformationMessage(
|
||||
'Please open Qwen Code chat first before logging in.',
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(...disposables);
|
||||
}
|
||||
24
packages/vscode-ide-companion/src/constants/acpSchema.ts
Normal file
24
packages/vscode-ide-companion/src/constants/acpSchema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const AGENT_METHODS = {
|
||||
authenticate: 'authenticate',
|
||||
initialize: 'initialize',
|
||||
session_cancel: 'session/cancel',
|
||||
session_list: 'session/list',
|
||||
session_load: 'session/load',
|
||||
session_new: 'session/new',
|
||||
session_prompt: 'session/prompt',
|
||||
session_save: 'session/save',
|
||||
session_set_mode: 'session/set_mode',
|
||||
} as const;
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
fs_read_text_file: 'fs/read_text_file',
|
||||
fs_write_text_file: 'fs/write_text_file',
|
||||
session_request_permission: 'session/request_permission',
|
||||
session_update: 'session/update',
|
||||
} as const;
|
||||
146
packages/vscode-ide-companion/src/constants/loadingMessages.ts
Normal file
146
packages/vscode-ide-companion/src/constants/loadingMessages.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Loading messages from Qwen Code CLI
|
||||
* Source: packages/cli/src/ui/hooks/usePhraseCycler.ts
|
||||
*/
|
||||
export const WITTY_LOADING_PHRASES = [
|
||||
"I'm Feeling Lucky",
|
||||
'Shipping awesomeness... ',
|
||||
'Painting the serifs back on...',
|
||||
'Navigating the slime mold...',
|
||||
'Consulting the digital spirits...',
|
||||
'Reticulating splines...',
|
||||
'Warming up the AI hamsters...',
|
||||
'Asking the magic conch shell...',
|
||||
'Generating witty retort...',
|
||||
'Polishing the algorithms...',
|
||||
"Don't rush perfection (or my code)...",
|
||||
'Brewing fresh bytes...',
|
||||
'Counting electrons...',
|
||||
'Engaging cognitive processors...',
|
||||
'Checking for syntax errors in the universe...',
|
||||
'One moment, optimizing humor...',
|
||||
'Shuffling punchlines...',
|
||||
'Untangling neural nets...',
|
||||
'Compiling brilliance...',
|
||||
'Loading wit.exe...',
|
||||
'Summoning the cloud of wisdom...',
|
||||
'Preparing a witty response...',
|
||||
"Just a sec, I'm debugging reality...",
|
||||
'Confuzzling the options...',
|
||||
'Tuning the cosmic frequencies...',
|
||||
'Crafting a response worthy of your patience...',
|
||||
'Compiling the 1s and 0s...',
|
||||
'Resolving dependencies... and existential crises...',
|
||||
'Defragmenting memories... both RAM and personal...',
|
||||
'Rebooting the humor module...',
|
||||
'Caching the essentials (mostly cat memes)...',
|
||||
'Optimizing for ludicrous speed',
|
||||
"Swapping bits... don't tell the bytes...",
|
||||
'Garbage collecting... be right back...',
|
||||
'Assembling the interwebs...',
|
||||
'Converting coffee into code...',
|
||||
'Updating the syntax for reality...',
|
||||
'Rewiring the synapses...',
|
||||
'Looking for a misplaced semicolon...',
|
||||
"Greasin' the cogs of the machine...",
|
||||
'Pre-heating the servers...',
|
||||
'Calibrating the flux capacitor...',
|
||||
'Engaging the improbability drive...',
|
||||
'Channeling the Force...',
|
||||
'Aligning the stars for optimal response...',
|
||||
'So say we all...',
|
||||
'Loading the next great idea...',
|
||||
"Just a moment, I'm in the zone...",
|
||||
'Preparing to dazzle you with brilliance...',
|
||||
"Just a tick, I'm polishing my wit...",
|
||||
"Hold tight, I'm crafting a masterpiece...",
|
||||
"Just a jiffy, I'm debugging the universe...",
|
||||
"Just a moment, I'm aligning the pixels...",
|
||||
"Just a sec, I'm optimizing the humor...",
|
||||
"Just a moment, I'm tuning the algorithms...",
|
||||
'Warp speed engaged...',
|
||||
'Mining for more Dilithium crystals...',
|
||||
"Don't panic...",
|
||||
'Following the white rabbit...',
|
||||
'The truth is in here... somewhere...',
|
||||
'Blowing on the cartridge...',
|
||||
'Loading... Do a barrel roll!',
|
||||
'Waiting for the respawn...',
|
||||
'Finishing the Kessel Run in less than 12 parsecs...',
|
||||
"The cake is not a lie, it's just still loading...",
|
||||
'Fiddling with the character creation screen...',
|
||||
"Just a moment, I'm finding the right meme...",
|
||||
"Pressing 'A' to continue...",
|
||||
'Herding digital cats...',
|
||||
'Polishing the pixels...',
|
||||
'Finding a suitable loading screen pun...',
|
||||
'Distracting you with this witty phrase...',
|
||||
'Almost there... probably...',
|
||||
'Our hamsters are working as fast as they can...',
|
||||
'Giving Cloudy a pat on the head...',
|
||||
'Petting the cat...',
|
||||
'Rickrolling my boss...',
|
||||
'Never gonna give you up, never gonna let you down...',
|
||||
'Slapping the bass...',
|
||||
'Tasting the snozberries...',
|
||||
"I'm going the distance, I'm going for speed...",
|
||||
'Is this the real life? Is this just fantasy?...',
|
||||
"I've got a good feeling about this...",
|
||||
'Poking the bear...',
|
||||
'Doing research on the latest memes...',
|
||||
'Figuring out how to make this more witty...',
|
||||
'Hmmm... let me think...',
|
||||
'What do you call a fish with no eyes? A fsh...',
|
||||
'Why did the computer go to therapy? It had too many bytes...',
|
||||
"Why don't programmers like nature? It has too many bugs...",
|
||||
'Why do programmers prefer dark mode? Because light attracts bugs...',
|
||||
'Why did the developer go broke? Because they used up all their cache...',
|
||||
"What can you do with a broken pencil? Nothing, it's pointless...",
|
||||
'Applying percussive maintenance...',
|
||||
'Searching for the correct USB orientation...',
|
||||
'Ensuring the magic smoke stays inside the wires...',
|
||||
'Rewriting in Rust for no particular reason...',
|
||||
'Trying to exit Vim...',
|
||||
'Spinning up the hamster wheel...',
|
||||
"That's not a bug, it's an undocumented feature...",
|
||||
'Engage.',
|
||||
"I'll be back... with an answer.",
|
||||
'My other process is a TARDIS...',
|
||||
'Communing with the machine spirit...',
|
||||
'Letting the thoughts marinate...',
|
||||
'Just remembered where I put my keys...',
|
||||
'Pondering the orb...',
|
||||
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
|
||||
'Initiating thoughtful gaze...',
|
||||
"What's a computer's favorite snack? Microchips.",
|
||||
"Why do Java developers wear glasses? Because they don't C#.",
|
||||
'Charging the laser... pew pew!',
|
||||
'Dividing by zero... just kidding!',
|
||||
'Looking for an adult superviso... I mean, processing.',
|
||||
'Making it go beep boop.',
|
||||
'Buffering... because even AIs need a moment.',
|
||||
'Entangling quantum particles for a faster response...',
|
||||
'Polishing the chrome... on the algorithms.',
|
||||
'Are you not entertained? (Working on it!)',
|
||||
'Summoning the code gremlins... to help, of course.',
|
||||
'Just waiting for the dial-up tone to finish...',
|
||||
'Recalibrating the humor-o-meter.',
|
||||
'My other loading screen is even funnier.',
|
||||
"Pretty sure there's a cat walking on the keyboard somewhere...",
|
||||
'Enhancing... Enhancing... Still loading.',
|
||||
"It's not a bug, it's a feature... of this loading screen.",
|
||||
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||
'Constructing additional pylons...',
|
||||
"New line? That's Ctrl+J.",
|
||||
];
|
||||
|
||||
export const getRandomLoadingMessage = (): string =>
|
||||
WITTY_LOADING_PHRASES[
|
||||
Math.floor(Math.random() * WITTY_LOADING_PHRASES.length)
|
||||
];
|
||||
@@ -12,6 +12,10 @@ import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js';
|
||||
import * as path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { DIFF_SCHEME } from './extension.js';
|
||||
import {
|
||||
findLeftGroupOfChatWebview,
|
||||
ensureLeftGroupOfChatWebview,
|
||||
} from './utils/editorGroupUtils.js';
|
||||
|
||||
export class DiffContentProvider implements vscode.TextDocumentContentProvider {
|
||||
private content = new Map<string, string>();
|
||||
@@ -42,7 +46,9 @@ export class DiffContentProvider implements vscode.TextDocumentContentProvider {
|
||||
// Information about a diff view that is currently open.
|
||||
interface DiffInfo {
|
||||
originalFilePath: string;
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
leftDocUri: vscode.Uri;
|
||||
rightDocUri: vscode.Uri;
|
||||
}
|
||||
|
||||
@@ -55,11 +61,26 @@ export class DiffManager {
|
||||
readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
private diffDocuments = new Map<string, DiffInfo>();
|
||||
private readonly subscriptions: vscode.Disposable[] = [];
|
||||
// Dedupe: remember recent showDiff calls keyed by (file+content)
|
||||
private recentlyShown = new Map<string, number>();
|
||||
private pendingDelayTimers = new Map<string, NodeJS.Timeout>();
|
||||
private static readonly DEDUPE_WINDOW_MS = 1500;
|
||||
// Optional hooks from extension to influence diff behavior
|
||||
// - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open)
|
||||
// - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode)
|
||||
private shouldDelay?: () => boolean;
|
||||
private shouldSuppress?: () => boolean;
|
||||
// Timed suppression window (e.g. immediately after permission allow)
|
||||
private suppressUntil: number | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly log: (message: string) => void,
|
||||
private readonly diffContentProvider: DiffContentProvider,
|
||||
shouldDelay?: () => boolean,
|
||||
shouldSuppress?: () => boolean,
|
||||
) {
|
||||
this.shouldDelay = shouldDelay;
|
||||
this.shouldSuppress = shouldSuppress;
|
||||
this.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor((editor) => {
|
||||
this.onActiveEditorChange(editor);
|
||||
@@ -75,43 +96,142 @@ export class DiffManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows a new diff view.
|
||||
* Checks if a diff view already exists for the given file path and content
|
||||
* @param filePath Path to the file being diffed
|
||||
* @param oldContent The original content (left side)
|
||||
* @param newContent The modified content (right side)
|
||||
* @returns True if a diff view with the same content already exists, false otherwise
|
||||
*/
|
||||
async showDiff(filePath: string, newContent: string) {
|
||||
const fileUri = vscode.Uri.file(filePath);
|
||||
private hasExistingDiff(
|
||||
filePath: string,
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
): boolean {
|
||||
for (const diffInfo of this.diffDocuments.values()) {
|
||||
if (
|
||||
diffInfo.originalFilePath === filePath &&
|
||||
diffInfo.oldContent === oldContent &&
|
||||
diffInfo.newContent === newContent
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an existing diff view for the given file path and focuses it
|
||||
* @param filePath Path to the file being diffed
|
||||
* @returns True if an existing diff view was found and focused, false otherwise
|
||||
*/
|
||||
private async focusExistingDiff(filePath: string): Promise<boolean> {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === normalizedPath) {
|
||||
const rightDocUri = diffInfo.rightDocUri;
|
||||
const leftDocUri = diffInfo.leftDocUri;
|
||||
|
||||
const diffTitle = `${path.basename(filePath)} (Before ↔ After)`;
|
||||
|
||||
try {
|
||||
await vscode.commands.executeCommand(
|
||||
'vscode.diff',
|
||||
leftDocUri,
|
||||
rightDocUri,
|
||||
diffTitle,
|
||||
{
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preview: false,
|
||||
preserveFocus: true,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log(`Failed to focus existing diff: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows a new diff view.
|
||||
* - Overload 1: showDiff(filePath, newContent)
|
||||
* - Overload 2: showDiff(filePath, oldContent, newContent)
|
||||
* If only newContent is provided, the old content will be read from the
|
||||
* filesystem (empty string when file does not exist).
|
||||
*/
|
||||
async showDiff(filePath: string, newContent: string): Promise<void>;
|
||||
async showDiff(
|
||||
filePath: string,
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
): Promise<void>;
|
||||
async showDiff(filePath: string, a: string, b?: string): Promise<void> {
|
||||
const haveOld = typeof b === 'string';
|
||||
const oldContent = haveOld ? a : await this.readOldContentFromFs(filePath);
|
||||
const newContent = haveOld ? (b as string) : a;
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
const key = this.makeKey(normalizedPath, oldContent, newContent);
|
||||
|
||||
// Check if a diff view with the same content already exists
|
||||
if (this.hasExistingDiff(normalizedPath, oldContent, newContent)) {
|
||||
const last = this.recentlyShown.get(key) || 0;
|
||||
const now = Date.now();
|
||||
if (now - last < DiffManager.DEDUPE_WINDOW_MS) {
|
||||
// Within dedupe window: ignore the duplicate request entirely
|
||||
this.log(
|
||||
`Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Outside the dedupe window: softly focus the existing diff
|
||||
await this.focusExistingDiff(normalizedPath);
|
||||
this.recentlyShown.set(key, now);
|
||||
return;
|
||||
}
|
||||
// Left side: old content using qwen-diff scheme
|
||||
const leftDocUri = vscode.Uri.from({
|
||||
scheme: DIFF_SCHEME,
|
||||
path: normalizedPath,
|
||||
query: `old&rand=${Math.random()}`,
|
||||
});
|
||||
this.diffContentProvider.setContent(leftDocUri, oldContent);
|
||||
|
||||
// Right side: new content using qwen-diff scheme
|
||||
const rightDocUri = vscode.Uri.from({
|
||||
scheme: DIFF_SCHEME,
|
||||
path: filePath,
|
||||
// cache busting
|
||||
query: `rand=${Math.random()}`,
|
||||
path: normalizedPath,
|
||||
query: `new&rand=${Math.random()}`,
|
||||
});
|
||||
this.diffContentProvider.setContent(rightDocUri, newContent);
|
||||
|
||||
this.addDiffDocument(rightDocUri, {
|
||||
originalFilePath: filePath,
|
||||
originalFilePath: normalizedPath,
|
||||
oldContent,
|
||||
newContent,
|
||||
leftDocUri,
|
||||
rightDocUri,
|
||||
});
|
||||
|
||||
const diffTitle = `${path.basename(filePath)} ↔ Modified`;
|
||||
const diffTitle = `${path.basename(normalizedPath)} (Before ↔ After)`;
|
||||
await vscode.commands.executeCommand(
|
||||
'setContext',
|
||||
'qwen.diff.isVisible',
|
||||
true,
|
||||
);
|
||||
|
||||
let leftDocUri;
|
||||
try {
|
||||
await vscode.workspace.fs.stat(fileUri);
|
||||
leftDocUri = fileUri;
|
||||
} catch {
|
||||
// We need to provide an empty document to diff against.
|
||||
// Using the 'untitled' scheme is one way to do this.
|
||||
leftDocUri = vscode.Uri.from({
|
||||
scheme: 'untitled',
|
||||
path: filePath,
|
||||
});
|
||||
// Prefer opening the diff adjacent to the chat webview (so we don't
|
||||
// replace content inside the locked webview group). We try the group to
|
||||
// the left of the chat webview first; if none exists we fall back to
|
||||
// ViewColumn.Beside. With the chat locked in the leftmost group, this
|
||||
// fallback opens diffs to the right of the chat.
|
||||
let targetViewColumn = findLeftGroupOfChatWebview();
|
||||
if (targetViewColumn === undefined) {
|
||||
// If there is no left neighbor, create one to satisfy the requirement of
|
||||
// opening diffs to the left of the chat webview.
|
||||
targetViewColumn = await ensureLeftGroupOfChatWebview();
|
||||
}
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
@@ -120,6 +240,10 @@ export class DiffManager {
|
||||
rightDocUri,
|
||||
diffTitle,
|
||||
{
|
||||
// If a left-of-webview group was found, target it explicitly so the
|
||||
// diff opens there while keeping focus on the webview. Otherwise, use
|
||||
// the default "open to side" behavior.
|
||||
viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside,
|
||||
preview: false,
|
||||
preserveFocus: true,
|
||||
},
|
||||
@@ -127,16 +251,19 @@ export class DiffManager {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.files.setActiveEditorWriteableInSession',
|
||||
);
|
||||
|
||||
this.recentlyShown.set(key, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes an open diff view for a specific file.
|
||||
*/
|
||||
async closeDiff(filePath: string, suppressNotification = false) {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
let uriToClose: vscode.Uri | undefined;
|
||||
for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === filePath) {
|
||||
uriToClose = vscode.Uri.parse(uriString);
|
||||
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === normalizedPath) {
|
||||
uriToClose = diffInfo.rightDocUri;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -267,4 +394,40 @@ export class DiffManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close all open qwen-diff editors */
|
||||
async closeAll(): Promise<void> {
|
||||
// Collect keys first to avoid iterator invalidation while closing
|
||||
const uris = Array.from(this.diffDocuments.keys()).map((k) =>
|
||||
vscode.Uri.parse(k),
|
||||
);
|
||||
for (const uri of uris) {
|
||||
try {
|
||||
await this.closeDiffEditor(uri);
|
||||
} catch (err) {
|
||||
this.log(`Failed to close diff editor: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read the current content of file from the workspace; return empty string if not found
|
||||
private async readOldContentFromFs(filePath: string): Promise<string> {
|
||||
try {
|
||||
const fileUri = vscode.Uri.file(filePath);
|
||||
const document = await vscode.workspace.openTextDocument(fileUri);
|
||||
return document.getText();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private makeKey(filePath: string, oldContent: string, newContent: string) {
|
||||
// Simple stable key; content could be large but kept transiently
|
||||
return `${filePath}\u241F${oldContent}\u241F${newContent}`;
|
||||
}
|
||||
|
||||
/** Temporarily suppress opening diffs for a short duration. */
|
||||
suppressFor(durationMs: number): void {
|
||||
this.suppressUntil = Date.now() + Math.max(0, durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ vi.mock('vscode', () => ({
|
||||
},
|
||||
showTextDocument: vi.fn(),
|
||||
showWorkspaceFolderPick: vi.fn(),
|
||||
registerWebviewPanelSerializer: vi.fn(() => ({
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [],
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
IDE_DEFINITIONS,
|
||||
type IdeInfo,
|
||||
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
|
||||
import { WebViewProvider } from './webview/WebViewProvider.js';
|
||||
import { registerNewCommands } from './commands/index.js';
|
||||
|
||||
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
|
||||
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
|
||||
@@ -31,6 +33,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet<IdeInfo['name']> = new Set([
|
||||
|
||||
let ideServer: IDEServer;
|
||||
let logger: vscode.OutputChannel;
|
||||
let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs
|
||||
|
||||
let log: (message: string) => void = () => {};
|
||||
|
||||
@@ -108,7 +111,75 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
checkForUpdates(context, log);
|
||||
|
||||
const diffContentProvider = new DiffContentProvider();
|
||||
const diffManager = new DiffManager(log, diffContentProvider);
|
||||
const diffManager = new DiffManager(
|
||||
log,
|
||||
diffContentProvider,
|
||||
// Delay when any chat tab has a pending permission drawer
|
||||
() => webViewProviders.some((p) => p.hasPendingPermission()),
|
||||
// Suppress diffs when active mode is auto or yolo in any chat tab
|
||||
() => {
|
||||
const providers = webViewProviders.filter(
|
||||
(p) => typeof p.shouldSuppressDiff === 'function',
|
||||
);
|
||||
if (providers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return providers.every((p) => p.shouldSuppressDiff());
|
||||
},
|
||||
);
|
||||
|
||||
// Helper function to create a new WebView provider instance
|
||||
const createWebViewProvider = (): WebViewProvider => {
|
||||
const provider = new WebViewProvider(context, context.extensionUri);
|
||||
webViewProviders.push(provider);
|
||||
return provider;
|
||||
};
|
||||
|
||||
// Register WebView panel serializer for persistence across reloads
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewPanelSerializer('qwenCode.chat', {
|
||||
async deserializeWebviewPanel(
|
||||
webviewPanel: vscode.WebviewPanel,
|
||||
state: unknown,
|
||||
) {
|
||||
console.log(
|
||||
'[Extension] Deserializing WebView panel with state:',
|
||||
state,
|
||||
);
|
||||
|
||||
// Create a new provider for the restored panel
|
||||
const provider = createWebViewProvider();
|
||||
console.log('[Extension] Provider created for deserialization');
|
||||
|
||||
// Restore state if available BEFORE restoring the panel
|
||||
if (state && typeof state === 'object') {
|
||||
console.log('[Extension] Restoring state:', state);
|
||||
provider.restoreState(
|
||||
state as {
|
||||
conversationId: string | null;
|
||||
agentInitialized: boolean;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.log('[Extension] No state to restore or invalid state');
|
||||
}
|
||||
|
||||
await provider.restorePanel(webviewPanel);
|
||||
console.log('[Extension] Panel restore completed');
|
||||
|
||||
log('WebView panel restored from serialization');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Register newly added commands via commands module
|
||||
registerNewCommands(
|
||||
context,
|
||||
log,
|
||||
diffManager,
|
||||
() => webViewProviders,
|
||||
createWebViewProvider,
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidCloseTextDocument((doc) => {
|
||||
@@ -120,17 +191,53 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
DIFF_SCHEME,
|
||||
diffContentProvider,
|
||||
),
|
||||
vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => {
|
||||
(vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => {
|
||||
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
if (docUri && docUri.scheme === DIFF_SCHEME) {
|
||||
diffManager.acceptDiff(docUri);
|
||||
}
|
||||
// If WebView is requesting permission, actively select an allow option (prefer once)
|
||||
try {
|
||||
for (const provider of webViewProviders) {
|
||||
if (provider?.hasPendingPermission()) {
|
||||
provider.respondToPendingPermission('allow');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Extension] Auto-allow on diff.accept failed:', err);
|
||||
}
|
||||
console.log('[Extension] Diff accepted');
|
||||
}),
|
||||
vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => {
|
||||
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
if (docUri && docUri.scheme === DIFF_SCHEME) {
|
||||
diffManager.cancelDiff(docUri);
|
||||
}
|
||||
// If WebView is requesting permission, actively select reject/cancel
|
||||
try {
|
||||
for (const provider of webViewProviders) {
|
||||
if (provider?.hasPendingPermission()) {
|
||||
provider.respondToPendingPermission('cancel');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Extension] Auto-reject on diff.cancel failed:', err);
|
||||
}
|
||||
console.log('[Extension] Diff cancelled');
|
||||
})),
|
||||
vscode.commands.registerCommand('qwen.diff.closeAll', async () => {
|
||||
try {
|
||||
await diffManager.closeAll();
|
||||
} catch (err) {
|
||||
console.warn('[Extension] qwen.diff.closeAll failed:', err);
|
||||
}
|
||||
}),
|
||||
vscode.commands.registerCommand('qwen.diff.suppressBriefly', async () => {
|
||||
try {
|
||||
diffManager.suppressFor(1200);
|
||||
} catch (err) {
|
||||
console.warn('[Extension] qwen.diff.suppressBriefly failed:', err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -160,34 +267,42 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
vscode.workspace.onDidGrantWorkspaceTrust(() => {
|
||||
ideServer.syncEnvVars();
|
||||
}),
|
||||
vscode.commands.registerCommand('qwen-code.runQwenCode', async () => {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No folder open. Please open a folder to run Qwen Code.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
vscode.commands.registerCommand(
|
||||
'qwen-code.runQwenCode',
|
||||
async (
|
||||
location?:
|
||||
| vscode.TerminalLocation
|
||||
| vscode.TerminalEditorLocationOptions,
|
||||
) => {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No folder open. Please open a folder to run Qwen Code.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedFolder: vscode.WorkspaceFolder | undefined;
|
||||
if (workspaceFolders.length === 1) {
|
||||
selectedFolder = workspaceFolders[0];
|
||||
} else {
|
||||
selectedFolder = await vscode.window.showWorkspaceFolderPick({
|
||||
placeHolder: 'Select a folder to run Qwen Code in',
|
||||
});
|
||||
}
|
||||
let selectedFolder: vscode.WorkspaceFolder | undefined;
|
||||
if (workspaceFolders.length === 1) {
|
||||
selectedFolder = workspaceFolders[0];
|
||||
} else {
|
||||
selectedFolder = await vscode.window.showWorkspaceFolderPick({
|
||||
placeHolder: 'Select a folder to run Qwen Code in',
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedFolder) {
|
||||
const qwenCmd = 'qwen';
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: `Qwen Code (${selectedFolder.name})`,
|
||||
cwd: selectedFolder.uri.fsPath,
|
||||
});
|
||||
terminal.show();
|
||||
terminal.sendText(qwenCmd);
|
||||
}
|
||||
}),
|
||||
if (selectedFolder) {
|
||||
const qwenCmd = 'qwen';
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: `Qwen Code (${selectedFolder.name})`,
|
||||
cwd: selectedFolder.uri.fsPath,
|
||||
location,
|
||||
});
|
||||
terminal.show();
|
||||
terminal.sendText(qwenCmd);
|
||||
}
|
||||
},
|
||||
),
|
||||
vscode.commands.registerCommand('qwen-code.showNotices', async () => {
|
||||
const noticePath = vscode.Uri.joinPath(
|
||||
context.extensionUri,
|
||||
@@ -204,6 +319,11 @@ export async function deactivate(): Promise<void> {
|
||||
if (ideServer) {
|
||||
await ideServer.stop();
|
||||
}
|
||||
// Dispose all WebView providers
|
||||
webViewProviders.forEach((provider) => {
|
||||
provider.dispose();
|
||||
});
|
||||
webViewProviders = [];
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log(`Failed to stop IDE server during deactivation: ${message}`);
|
||||
|
||||
@@ -437,6 +437,7 @@ const createMcpServer = (diffManager: DiffManager) => {
|
||||
inputSchema: OpenDiffRequestSchema.shape,
|
||||
},
|
||||
async ({ filePath, newContent }: z.infer<typeof OpenDiffRequestSchema>) => {
|
||||
// Minimal call site: only pass newContent; DiffManager reads old content itself
|
||||
await diffManager.showDiff(filePath, newContent);
|
||||
return { content: [] };
|
||||
},
|
||||
|
||||
@@ -414,7 +414,7 @@ describe('OpenFilesManager', () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
file1 = manager.state.workspaceState!.openFiles!.find(
|
||||
(f) => f.path === '/test/file1.txt',
|
||||
(f: { path: string }) => f.path === '/test/file1.txt',
|
||||
)!;
|
||||
const file2 = manager.state.workspaceState!.openFiles![0];
|
||||
|
||||
|
||||
426
packages/vscode-ide-companion/src/services/acpConnection.ts
Normal file
426
packages/vscode-ide-companion/src/services/acpConnection.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { JSONRPC_VERSION } from '../types/acpTypes.js';
|
||||
import type {
|
||||
AcpMessage,
|
||||
AcpPermissionRequest,
|
||||
AcpResponse,
|
||||
AcpSessionUpdate,
|
||||
ApprovalModeValue,
|
||||
} from '../types/acpTypes.js';
|
||||
import type { ChildProcess, SpawnOptions } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import type {
|
||||
PendingRequest,
|
||||
AcpConnectionCallbacks,
|
||||
} from '../types/connectionTypes.js';
|
||||
import { AcpMessageHandler } from './acpMessageHandler.js';
|
||||
import { AcpSessionManager } from './acpSessionManager.js';
|
||||
import { determineNodePathForCli } from '../cli/cliPathDetector.js';
|
||||
|
||||
/**
|
||||
* ACP Connection Handler for VSCode Extension
|
||||
*
|
||||
* This class implements the client side of the ACP (Agent Communication Protocol).
|
||||
*/
|
||||
export class AcpConnection {
|
||||
private child: ChildProcess | null = null;
|
||||
private pendingRequests = new Map<number, PendingRequest<unknown>>();
|
||||
private nextRequestId = { value: 0 };
|
||||
// Remember the working dir provided at connect() so later ACP calls
|
||||
// that require cwd (e.g. session/list) can include it.
|
||||
private workingDir: string = process.cwd();
|
||||
|
||||
private messageHandler: AcpMessageHandler;
|
||||
private sessionManager: AcpSessionManager;
|
||||
|
||||
onSessionUpdate: (data: AcpSessionUpdate) => void = () => {};
|
||||
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
||||
optionId: string;
|
||||
}> = () => Promise.resolve({ optionId: 'allow' });
|
||||
onEndTurn: () => void = () => {};
|
||||
// Called after successful initialize() with the initialize result
|
||||
onInitialized: (init: unknown) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
this.messageHandler = new AcpMessageHandler();
|
||||
this.sessionManager = new AcpSessionManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Qwen ACP
|
||||
*
|
||||
* @param cliPath - CLI path
|
||||
* @param workingDir - Working directory
|
||||
* @param extraArgs - Extra command line arguments
|
||||
*/
|
||||
async connect(
|
||||
cliPath: string,
|
||||
workingDir: string = process.cwd(),
|
||||
extraArgs: string[] = [],
|
||||
): Promise<void> {
|
||||
if (this.child) {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
this.workingDir = workingDir;
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const env = { ...process.env };
|
||||
|
||||
// If proxy is configured in extraArgs, also set it as environment variable
|
||||
// This ensures token refresh requests also use the proxy
|
||||
const proxyArg = extraArgs.find(
|
||||
(arg, i) => arg === '--proxy' && i + 1 < extraArgs.length,
|
||||
);
|
||||
if (proxyArg) {
|
||||
const proxyIndex = extraArgs.indexOf('--proxy');
|
||||
const proxyUrl = extraArgs[proxyIndex + 1];
|
||||
console.log('[ACP] Setting proxy environment variables:', proxyUrl);
|
||||
|
||||
env['HTTP_PROXY'] = proxyUrl;
|
||||
env['HTTPS_PROXY'] = proxyUrl;
|
||||
env['http_proxy'] = proxyUrl;
|
||||
env['https_proxy'] = proxyUrl;
|
||||
}
|
||||
|
||||
let spawnCommand: string;
|
||||
let spawnArgs: string[];
|
||||
|
||||
if (cliPath.startsWith('npx ')) {
|
||||
const parts = cliPath.split(' ');
|
||||
spawnCommand = isWindows ? 'npx.cmd' : 'npx';
|
||||
spawnArgs = [...parts.slice(1), '--experimental-acp', ...extraArgs];
|
||||
} else {
|
||||
// For qwen CLI, ensure we use the correct Node.js version
|
||||
// Handle various Node.js version managers (nvm, n, manual installations)
|
||||
if (cliPath.includes('/qwen') && !isWindows) {
|
||||
// Try to determine the correct node executable for this qwen installation
|
||||
const nodePathResult = determineNodePathForCli(cliPath);
|
||||
if (nodePathResult.path) {
|
||||
spawnCommand = nodePathResult.path;
|
||||
spawnArgs = [cliPath, '--experimental-acp', ...extraArgs];
|
||||
} else {
|
||||
// Fallback to direct execution
|
||||
spawnCommand = cliPath;
|
||||
spawnArgs = ['--experimental-acp', ...extraArgs];
|
||||
|
||||
// Log any error for debugging
|
||||
if (nodePathResult.error) {
|
||||
console.warn(
|
||||
`[ACP] Node.js path detection warning: ${nodePathResult.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spawnCommand = cliPath;
|
||||
spawnArgs = ['--experimental-acp', ...extraArgs];
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' '));
|
||||
|
||||
const options: SpawnOptions = {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
shell: isWindows,
|
||||
};
|
||||
|
||||
this.child = spawn(spawnCommand, spawnArgs, options);
|
||||
await this.setupChildProcessHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up child process handlers
|
||||
*/
|
||||
private async setupChildProcessHandlers(): Promise<void> {
|
||||
let spawnError: Error | null = null;
|
||||
|
||||
this.child!.stderr?.on('data', (data) => {
|
||||
const message = data.toString();
|
||||
if (
|
||||
message.toLowerCase().includes('error') &&
|
||||
!message.includes('Loaded cached')
|
||||
) {
|
||||
console.error(`[ACP qwen]:`, message);
|
||||
} else {
|
||||
console.log(`[ACP qwen]:`, message);
|
||||
}
|
||||
});
|
||||
|
||||
this.child!.on('error', (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
|
||||
this.child!.on('exit', (code, signal) => {
|
||||
console.error(
|
||||
`[ACP qwen] Process exited with code: ${code}, signal: ${signal}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for process to start
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (spawnError) {
|
||||
throw spawnError;
|
||||
}
|
||||
|
||||
if (!this.child || this.child.killed) {
|
||||
throw new Error(`Qwen ACP process failed to start`);
|
||||
}
|
||||
|
||||
// Handle messages from ACP server
|
||||
let buffer = '';
|
||||
this.child.stdout?.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const message = JSON.parse(line) as AcpMessage;
|
||||
console.log(
|
||||
'[ACP] <<< Received message:',
|
||||
JSON.stringify(message).substring(0, 500 * 3),
|
||||
);
|
||||
this.handleMessage(message);
|
||||
} catch (_error) {
|
||||
// Ignore non-JSON lines
|
||||
console.log(
|
||||
'[ACP] <<< Non-JSON line (ignored):',
|
||||
line.substring(0, 200),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize protocol
|
||||
const res = await this.sessionManager.initialize(
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
|
||||
console.log('[ACP] Initialization response:', res);
|
||||
try {
|
||||
this.onInitialized(res);
|
||||
} catch (err) {
|
||||
console.warn('[ACP] onInitialized callback error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle received messages
|
||||
*
|
||||
* @param message - ACP message
|
||||
*/
|
||||
private handleMessage(message: AcpMessage): void {
|
||||
const callbacks: AcpConnectionCallbacks = {
|
||||
onSessionUpdate: this.onSessionUpdate,
|
||||
onPermissionRequest: this.onPermissionRequest,
|
||||
onEndTurn: this.onEndTurn,
|
||||
};
|
||||
|
||||
// Handle message
|
||||
if ('method' in message) {
|
||||
// Request or notification
|
||||
this.messageHandler
|
||||
.handleIncomingRequest(message, callbacks)
|
||||
.then((result) => {
|
||||
if ('id' in message && typeof message.id === 'number') {
|
||||
this.messageHandler.sendResponseMessage(this.child, {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: message.id,
|
||||
result,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if ('id' in message && typeof message.id === 'number') {
|
||||
this.messageHandler.sendResponseMessage(this.child, {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Response
|
||||
this.messageHandler.handleMessage(
|
||||
message,
|
||||
this.pendingRequests,
|
||||
callbacks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate
|
||||
*
|
||||
* @param methodId - Authentication method ID
|
||||
* @returns Authentication response
|
||||
*/
|
||||
async authenticate(methodId?: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.authenticate(
|
||||
methodId,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new session
|
||||
*
|
||||
* @param cwd - Working directory
|
||||
* @returns New session response
|
||||
*/
|
||||
async newSession(cwd: string = process.cwd()): Promise<AcpResponse> {
|
||||
return this.sessionManager.newSession(
|
||||
cwd,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt message
|
||||
*
|
||||
* @param prompt - Prompt content
|
||||
* @returns Response
|
||||
*/
|
||||
async sendPrompt(prompt: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.sendPrompt(
|
||||
prompt,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @returns Load response
|
||||
*/
|
||||
async loadSession(
|
||||
sessionId: string,
|
||||
cwdOverride?: string,
|
||||
): Promise<AcpResponse> {
|
||||
return this.sessionManager.loadSession(
|
||||
sessionId,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
cwdOverride || this.workingDir,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session list
|
||||
*
|
||||
* @returns Session list response
|
||||
*/
|
||||
async listSessions(options?: {
|
||||
cursor?: number;
|
||||
size?: number;
|
||||
}): Promise<AcpResponse> {
|
||||
return this.sessionManager.listSessions(
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
this.workingDir,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to specified session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @returns Switch response
|
||||
*/
|
||||
async switchSession(sessionId: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.switchSession(sessionId, this.nextRequestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel current session prompt generation
|
||||
*/
|
||||
async cancelSession(): Promise<void> {
|
||||
await this.sessionManager.cancelSession(this.child);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current session
|
||||
*
|
||||
* @param tag - Save tag
|
||||
* @returns Save response
|
||||
*/
|
||||
async saveSession(tag: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.saveSession(
|
||||
tag,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode
|
||||
*/
|
||||
async setMode(modeId: ApprovalModeValue): Promise<AcpResponse> {
|
||||
return this.sessionManager.setMode(
|
||||
modeId,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.child) {
|
||||
this.child.kill();
|
||||
this.child = null;
|
||||
}
|
||||
|
||||
this.pendingRequests.clear();
|
||||
this.sessionManager.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
get isConnected(): boolean {
|
||||
return this.child !== null && !this.child.killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is an active session
|
||||
*/
|
||||
get hasActiveSession(): boolean {
|
||||
return this.sessionManager.getCurrentSessionId() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID
|
||||
*/
|
||||
get currentSessionId(): string | null {
|
||||
return this.sessionManager.getCurrentSessionId();
|
||||
}
|
||||
}
|
||||
111
packages/vscode-ide-companion/src/services/acpFileHandler.ts
Normal file
111
packages/vscode-ide-companion/src/services/acpFileHandler.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ACP File Operation Handler
|
||||
*
|
||||
* Responsible for handling file read and write operations in the ACP protocol
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* ACP File Operation Handler Class
|
||||
* Provides file read and write functionality according to ACP protocol specifications
|
||||
*/
|
||||
export class AcpFileHandler {
|
||||
/**
|
||||
* Handle read text file request
|
||||
*
|
||||
* @param params - File read parameters
|
||||
* @param params.path - File path
|
||||
* @param params.sessionId - Session ID
|
||||
* @param params.line - Starting line number (optional)
|
||||
* @param params.limit - Read line limit (optional)
|
||||
* @returns File content
|
||||
* @throws Error when file reading fails
|
||||
*/
|
||||
async handleReadTextFile(params: {
|
||||
path: string;
|
||||
sessionId: string;
|
||||
line: number | null;
|
||||
limit: number | null;
|
||||
}): Promise<{ content: string }> {
|
||||
console.log(`[ACP] fs/read_text_file request received for: ${params.path}`);
|
||||
console.log(`[ACP] Parameters:`, {
|
||||
line: params.line,
|
||||
limit: params.limit,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(params.path, 'utf-8');
|
||||
console.log(
|
||||
`[ACP] Successfully read file: ${params.path} (${content.length} bytes)`,
|
||||
);
|
||||
|
||||
// Handle line offset and limit
|
||||
if (params.line !== null || params.limit !== null) {
|
||||
const lines = content.split('\n');
|
||||
const startLine = params.line || 0;
|
||||
const endLine = params.limit ? startLine + params.limit : lines.length;
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
const result = { content: selectedLines.join('\n') };
|
||||
console.log(`[ACP] Returning ${selectedLines.length} lines`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = { content };
|
||||
console.log(`[ACP] Returning full file content`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg);
|
||||
|
||||
throw new Error(`Failed to read file '${params.path}': ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle write text file request
|
||||
*
|
||||
* @param params - File write parameters
|
||||
* @param params.path - File path
|
||||
* @param params.content - File content
|
||||
* @param params.sessionId - Session ID
|
||||
* @returns null indicates success
|
||||
* @throws Error when file writing fails
|
||||
*/
|
||||
async handleWriteTextFile(params: {
|
||||
path: string;
|
||||
content: string;
|
||||
sessionId: string;
|
||||
}): Promise<null> {
|
||||
console.log(
|
||||
`[ACP] fs/write_text_file request received for: ${params.path}`,
|
||||
);
|
||||
console.log(`[ACP] Content size: ${params.content.length} bytes`);
|
||||
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dirName = path.dirname(params.path);
|
||||
console.log(`[ACP] Ensuring directory exists: ${dirName}`);
|
||||
await fs.mkdir(dirName, { recursive: true });
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(params.path, params.content, 'utf-8');
|
||||
|
||||
console.log(`[ACP] Successfully wrote file: ${params.path}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg);
|
||||
|
||||
throw new Error(`Failed to write file '${params.path}': ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
235
packages/vscode-ide-companion/src/services/acpMessageHandler.ts
Normal file
235
packages/vscode-ide-companion/src/services/acpMessageHandler.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ACP Message Handler
|
||||
*
|
||||
* Responsible for receiving, parsing, and distributing messages in the ACP protocol
|
||||
*/
|
||||
|
||||
import type {
|
||||
AcpMessage,
|
||||
AcpRequest,
|
||||
AcpNotification,
|
||||
AcpResponse,
|
||||
AcpSessionUpdate,
|
||||
AcpPermissionRequest,
|
||||
} from '../types/acpTypes.js';
|
||||
import { CLIENT_METHODS } from '../constants/acpSchema.js';
|
||||
import type {
|
||||
PendingRequest,
|
||||
AcpConnectionCallbacks,
|
||||
} from '../types/connectionTypes.js';
|
||||
import { AcpFileHandler } from '../services/acpFileHandler.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
/**
|
||||
* ACP Message Handler Class
|
||||
* Responsible for receiving, parsing, and processing messages
|
||||
*/
|
||||
export class AcpMessageHandler {
|
||||
private fileHandler: AcpFileHandler;
|
||||
|
||||
constructor() {
|
||||
this.fileHandler = new AcpFileHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response message to child process
|
||||
*
|
||||
* @param child - Child process instance
|
||||
* @param response - Response message
|
||||
*/
|
||||
sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void {
|
||||
if (child?.stdin) {
|
||||
const jsonString = JSON.stringify(response);
|
||||
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
|
||||
child.stdin.write(jsonString + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle received messages
|
||||
*
|
||||
* @param message - ACP message
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param callbacks - Callback functions collection
|
||||
*/
|
||||
handleMessage(
|
||||
message: AcpMessage,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
callbacks: AcpConnectionCallbacks,
|
||||
): void {
|
||||
try {
|
||||
if ('method' in message) {
|
||||
// Request or notification
|
||||
this.handleIncomingRequest(message, callbacks).catch(() => {});
|
||||
} else if (
|
||||
'id' in message &&
|
||||
typeof message.id === 'number' &&
|
||||
pendingRequests.has(message.id)
|
||||
) {
|
||||
// Response
|
||||
this.handleResponse(message, pendingRequests, callbacks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ACP] Error handling message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle response message
|
||||
*
|
||||
* @param message - Response message
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param callbacks - Callback functions collection
|
||||
*/
|
||||
private handleResponse(
|
||||
message: AcpMessage,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
callbacks: AcpConnectionCallbacks,
|
||||
): void {
|
||||
if (!('id' in message) || typeof message.id !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingRequest = pendingRequests.get(message.id);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { resolve, reject, method } = pendingRequest;
|
||||
pendingRequests.delete(message.id);
|
||||
|
||||
if ('result' in message) {
|
||||
console.log(
|
||||
`[ACP] Response for ${method}:`,
|
||||
// JSON.stringify(message.result).substring(0, 200),
|
||||
message.result,
|
||||
);
|
||||
if (
|
||||
message.result &&
|
||||
typeof message.result === 'object' &&
|
||||
'stopReason' in message.result &&
|
||||
message.result.stopReason === 'end_turn'
|
||||
) {
|
||||
callbacks.onEndTurn();
|
||||
}
|
||||
resolve(message.result);
|
||||
} else if ('error' in message) {
|
||||
const errorCode = message.error?.code || 'unknown';
|
||||
const errorMsg = message.error?.message || 'Unknown ACP error';
|
||||
const errorData = message.error?.data
|
||||
? JSON.stringify(message.error.data)
|
||||
: '';
|
||||
console.error(`[ACP] Error response for ${method}:`, {
|
||||
code: errorCode,
|
||||
message: errorMsg,
|
||||
data: errorData,
|
||||
});
|
||||
reject(
|
||||
new Error(
|
||||
`${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming requests
|
||||
*
|
||||
* @param message - Request or notification message
|
||||
* @param callbacks - Callback functions collection
|
||||
* @returns Request processing result
|
||||
*/
|
||||
async handleIncomingRequest(
|
||||
message: AcpRequest | AcpNotification,
|
||||
callbacks: AcpConnectionCallbacks,
|
||||
): Promise<unknown> {
|
||||
const { method, params } = message;
|
||||
|
||||
let result = null;
|
||||
|
||||
switch (method) {
|
||||
case CLIENT_METHODS.session_update:
|
||||
console.log(
|
||||
'[ACP] >>> Processing session_update:',
|
||||
JSON.stringify(params).substring(0, 300),
|
||||
);
|
||||
callbacks.onSessionUpdate(params as AcpSessionUpdate);
|
||||
break;
|
||||
case CLIENT_METHODS.session_request_permission:
|
||||
result = await this.handlePermissionRequest(
|
||||
params as AcpPermissionRequest,
|
||||
callbacks,
|
||||
);
|
||||
break;
|
||||
case CLIENT_METHODS.fs_read_text_file:
|
||||
result = await this.fileHandler.handleReadTextFile(
|
||||
params as {
|
||||
path: string;
|
||||
sessionId: string;
|
||||
line: number | null;
|
||||
limit: number | null;
|
||||
},
|
||||
);
|
||||
break;
|
||||
case CLIENT_METHODS.fs_write_text_file:
|
||||
result = await this.fileHandler.handleWriteTextFile(
|
||||
params as { path: string; content: string; sessionId: string },
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.warn(`[ACP] Unhandled method: ${method}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle permission requests
|
||||
*
|
||||
* @param params - Permission request parameters
|
||||
* @param callbacks - Callback functions collection
|
||||
* @returns Permission request result
|
||||
*/
|
||||
private async handlePermissionRequest(
|
||||
params: AcpPermissionRequest,
|
||||
callbacks: AcpConnectionCallbacks,
|
||||
): Promise<{
|
||||
outcome: { outcome: string; optionId: string };
|
||||
}> {
|
||||
try {
|
||||
const response = await callbacks.onPermissionRequest(params);
|
||||
const optionId = response?.optionId;
|
||||
console.log('[ACP] Permission request:', optionId);
|
||||
// Handle cancel, deny, or allow
|
||||
let outcome: string;
|
||||
if (optionId && (optionId.includes('reject') || optionId === 'cancel')) {
|
||||
outcome = 'cancelled';
|
||||
} else {
|
||||
outcome = 'selected';
|
||||
}
|
||||
console.log('[ACP] Permission outcome:', outcome);
|
||||
|
||||
return {
|
||||
outcome: {
|
||||
outcome,
|
||||
// optionId: optionId === 'cancel' ? 'cancel' : optionId,
|
||||
optionId,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'rejected',
|
||||
optionId: 'reject_once',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
474
packages/vscode-ide-companion/src/services/acpSessionManager.ts
Normal file
474
packages/vscode-ide-companion/src/services/acpSessionManager.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ACP Session Manager
|
||||
*
|
||||
* Responsible for managing ACP protocol session operations, including initialization, authentication, session creation, and switching
|
||||
*/
|
||||
import { JSONRPC_VERSION } from '../types/acpTypes.js';
|
||||
import type {
|
||||
AcpRequest,
|
||||
AcpNotification,
|
||||
AcpResponse,
|
||||
ApprovalModeValue,
|
||||
} from '../types/acpTypes.js';
|
||||
import { AGENT_METHODS } from '../constants/acpSchema.js';
|
||||
import type { PendingRequest } from '../types/connectionTypes.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
/**
|
||||
* ACP Session Manager Class
|
||||
* Provides session initialization, authentication, creation, loading, and switching functionality
|
||||
*/
|
||||
export class AcpSessionManager {
|
||||
private sessionId: string | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* Send request to ACP server
|
||||
*
|
||||
* @param method - Request method name
|
||||
* @param params - Request parameters
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Request response
|
||||
*/
|
||||
private sendRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<T> {
|
||||
const id = nextRequestId.value++;
|
||||
const message: AcpRequest = {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id,
|
||||
method,
|
||||
...(params && { params }),
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutDuration =
|
||||
method === AGENT_METHODS.session_prompt ? 120000 : 60000;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${method} timed out`));
|
||||
}, timeoutDuration);
|
||||
|
||||
const pendingRequest: PendingRequest<T> = {
|
||||
resolve: (value: T) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
timeoutId,
|
||||
method,
|
||||
};
|
||||
|
||||
pendingRequests.set(id, pendingRequest as PendingRequest<unknown>);
|
||||
this.sendMessage(message, child);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to child process
|
||||
*
|
||||
* @param message - Request or notification message
|
||||
* @param child - Child process instance
|
||||
*/
|
||||
private sendMessage(
|
||||
message: AcpRequest | AcpNotification,
|
||||
child: ChildProcess | null,
|
||||
): void {
|
||||
if (child?.stdin) {
|
||||
const jsonString = JSON.stringify(message);
|
||||
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
|
||||
child.stdin.write(jsonString + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize ACP protocol connection
|
||||
*
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Initialization response
|
||||
*/
|
||||
async initialize(
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
const initializeParams = {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
readTextFile: true,
|
||||
writeTextFile: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[ACP] Sending initialize request...');
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.initialize,
|
||||
initializeParams,
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
this.isInitialized = true;
|
||||
|
||||
console.log('[ACP] Initialize successful');
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform authentication
|
||||
*
|
||||
* @param methodId - Authentication method ID
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Authentication response
|
||||
*/
|
||||
async authenticate(
|
||||
methodId: string | undefined,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
const authMethodId = methodId || 'default';
|
||||
console.log(
|
||||
'[ACP] Sending authenticate request with methodId:',
|
||||
authMethodId,
|
||||
);
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.authenticate,
|
||||
{
|
||||
methodId: authMethodId,
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] Authenticate successful');
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new session
|
||||
*
|
||||
* @param cwd - Working directory
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns New session response
|
||||
*/
|
||||
async newSession(
|
||||
cwd: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Sending session/new request with cwd:', cwd);
|
||||
const response = await this.sendRequest<
|
||||
AcpResponse & { sessionId?: string }
|
||||
>(
|
||||
AGENT_METHODS.session_new,
|
||||
{
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
this.sessionId = (response && response.sessionId) || null;
|
||||
console.log('[ACP] Session created with ID:', this.sessionId);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt message
|
||||
*
|
||||
* @param prompt - Prompt content
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Response
|
||||
* @throws Error when there is no active session
|
||||
*/
|
||||
async sendPrompt(
|
||||
prompt: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
|
||||
return await this.sendRequest(
|
||||
AGENT_METHODS.session_prompt,
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Load response
|
||||
*/
|
||||
async loadSession(
|
||||
sessionId: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Sending session/load request for session:', sessionId);
|
||||
console.log('[ACP] Request parameters:', {
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_load,
|
||||
{
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
console.log(
|
||||
'[ACP] Session load response:',
|
||||
JSON.stringify(response).substring(0, 500),
|
||||
);
|
||||
|
||||
// Check if response contains an error
|
||||
if (response && response.error) {
|
||||
console.error('[ACP] Session load returned error:', response.error);
|
||||
} else {
|
||||
console.log('[ACP] Session load succeeded');
|
||||
// session/load returns null on success per schema; update local sessionId
|
||||
// so subsequent prompts use the loaded session.
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[ACP] Session load request failed with exception:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session list
|
||||
*
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Session list response
|
||||
*/
|
||||
async listSessions(
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
cwd: string = process.cwd(),
|
||||
options?: { cursor?: number; size?: number },
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Requesting session list...');
|
||||
try {
|
||||
// session/list requires cwd in params per ACP schema
|
||||
const params: Record<string, unknown> = { cwd };
|
||||
if (options?.cursor !== undefined) {
|
||||
params.cursor = options.cursor;
|
||||
}
|
||||
if (options?.size !== undefined) {
|
||||
params.size = options.size;
|
||||
}
|
||||
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_list,
|
||||
params,
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log(
|
||||
'[ACP] Session list response:',
|
||||
JSON.stringify(response).substring(0, 200),
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[ACP] Failed to get session list:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode for current session (ACP session/set_mode)
|
||||
*
|
||||
* @param modeId - Approval mode value
|
||||
*/
|
||||
async setMode(
|
||||
modeId: ApprovalModeValue,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
console.log('[ACP] Sending session/set_mode:', modeId);
|
||||
const res = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_set_mode,
|
||||
{ sessionId: this.sessionId, modeId },
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] set_mode response:', res);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to specified session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Switch response
|
||||
*/
|
||||
async switchSession(
|
||||
sessionId: string,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Switching to session:', sessionId);
|
||||
this.sessionId = sessionId;
|
||||
|
||||
const mockResponse: AcpResponse = {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: nextRequestId.value++,
|
||||
result: { sessionId },
|
||||
};
|
||||
console.log(
|
||||
'[ACP] Session ID updated locally (switch not supported by CLI)',
|
||||
);
|
||||
return mockResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel prompt generation for current session
|
||||
*
|
||||
* @param child - Child process instance
|
||||
*/
|
||||
async cancelSession(child: ChildProcess | null): Promise<void> {
|
||||
if (!this.sessionId) {
|
||||
console.warn('[ACP] No active session to cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ACP] Cancelling session:', this.sessionId);
|
||||
|
||||
const cancelParams = {
|
||||
sessionId: this.sessionId,
|
||||
};
|
||||
|
||||
const message: AcpNotification = {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
method: AGENT_METHODS.session_cancel,
|
||||
params: cancelParams,
|
||||
};
|
||||
|
||||
this.sendMessage(message, child);
|
||||
console.log('[ACP] Cancel notification sent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current session
|
||||
*
|
||||
* @param tag - Save tag
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Save response
|
||||
*/
|
||||
async saveSession(
|
||||
tag: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
|
||||
console.log('[ACP] Saving session with tag:', tag);
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_save,
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
tag,
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] Session save response:', response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset session manager state
|
||||
*/
|
||||
reset(): void {
|
||||
this.sessionId = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID
|
||||
*/
|
||||
getCurrentSessionId(): string | null {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if initialized
|
||||
*/
|
||||
getIsInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
}
|
||||
215
packages/vscode-ide-companion/src/services/authStateManager.ts
Normal file
215
packages/vscode-ide-companion/src/services/authStateManager.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type * as vscode from 'vscode';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
authMethod: string;
|
||||
timestamp: number;
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages authentication state caching to avoid repeated logins
|
||||
*/
|
||||
export class AuthStateManager {
|
||||
private static instance: AuthStateManager | null = null;
|
||||
private static context: vscode.ExtensionContext | null = null;
|
||||
private static readonly AUTH_STATE_KEY = 'qwen.authState';
|
||||
private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance of AuthStateManager
|
||||
*/
|
||||
static getInstance(context?: vscode.ExtensionContext): AuthStateManager {
|
||||
if (!AuthStateManager.instance) {
|
||||
AuthStateManager.instance = new AuthStateManager();
|
||||
}
|
||||
|
||||
// If a context is provided, update the static context
|
||||
if (context) {
|
||||
AuthStateManager.context = context;
|
||||
}
|
||||
|
||||
return AuthStateManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a valid cached authentication
|
||||
*/
|
||||
async hasValidAuth(workingDir: string, authMethod: string): Promise<boolean> {
|
||||
const state = await this.getAuthState();
|
||||
|
||||
if (!state) {
|
||||
console.log('[AuthStateManager] No cached auth state found');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AuthStateManager] Found cached auth state:', {
|
||||
workingDir: state.workingDir,
|
||||
authMethod: state.authMethod,
|
||||
timestamp: new Date(state.timestamp).toISOString(),
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
});
|
||||
console.log('[AuthStateManager] Checking against:', {
|
||||
workingDir,
|
||||
authMethod,
|
||||
});
|
||||
|
||||
// Check if auth is still valid (within cache duration)
|
||||
const now = Date.now();
|
||||
const isExpired =
|
||||
now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION;
|
||||
|
||||
if (isExpired) {
|
||||
console.log('[AuthStateManager] Cached auth expired');
|
||||
console.log(
|
||||
'[AuthStateManager] Cache age:',
|
||||
Math.floor((now - state.timestamp) / 1000 / 60),
|
||||
'minutes',
|
||||
);
|
||||
await this.clearAuthState();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's for the same working directory and auth method
|
||||
const isSameContext =
|
||||
state.workingDir === workingDir && state.authMethod === authMethod;
|
||||
|
||||
if (!isSameContext) {
|
||||
console.log('[AuthStateManager] Working dir or auth method changed');
|
||||
console.log('[AuthStateManager] Cached workingDir:', state.workingDir);
|
||||
console.log('[AuthStateManager] Current workingDir:', workingDir);
|
||||
console.log('[AuthStateManager] Cached authMethod:', state.authMethod);
|
||||
console.log('[AuthStateManager] Current authMethod:', authMethod);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AuthStateManager] Valid cached auth found');
|
||||
return state.isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force check auth state without clearing cache
|
||||
* This is useful for debugging to see what's actually cached
|
||||
*/
|
||||
async debugAuthState(): Promise<void> {
|
||||
const state = await this.getAuthState();
|
||||
console.log('[AuthStateManager] DEBUG - Current auth state:', state);
|
||||
|
||||
if (state) {
|
||||
const now = Date.now();
|
||||
const age = Math.floor((now - state.timestamp) / 1000 / 60);
|
||||
const isExpired =
|
||||
now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION;
|
||||
|
||||
console.log('[AuthStateManager] DEBUG - Auth state age:', age, 'minutes');
|
||||
console.log('[AuthStateManager] DEBUG - Auth state expired:', isExpired);
|
||||
console.log(
|
||||
'[AuthStateManager] DEBUG - Auth state valid:',
|
||||
state.isAuthenticated,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save successful authentication state
|
||||
*/
|
||||
async saveAuthState(workingDir: string, authMethod: string): Promise<void> {
|
||||
// Ensure we have a valid context
|
||||
if (!AuthStateManager.context) {
|
||||
throw new Error(
|
||||
'[AuthStateManager] No context available for saving auth state',
|
||||
);
|
||||
}
|
||||
|
||||
const state: AuthState = {
|
||||
isAuthenticated: true,
|
||||
authMethod,
|
||||
workingDir,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
console.log('[AuthStateManager] Saving auth state:', {
|
||||
workingDir,
|
||||
authMethod,
|
||||
timestamp: new Date(state.timestamp).toISOString(),
|
||||
});
|
||||
|
||||
await AuthStateManager.context.globalState.update(
|
||||
AuthStateManager.AUTH_STATE_KEY,
|
||||
state,
|
||||
);
|
||||
console.log('[AuthStateManager] Auth state saved');
|
||||
|
||||
// Verify the state was saved correctly
|
||||
const savedState = await this.getAuthState();
|
||||
console.log('[AuthStateManager] Verified saved state:', savedState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication state
|
||||
*/
|
||||
async clearAuthState(): Promise<void> {
|
||||
// Ensure we have a valid context
|
||||
if (!AuthStateManager.context) {
|
||||
throw new Error(
|
||||
'[AuthStateManager] No context available for clearing auth state',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[AuthStateManager] Clearing auth state');
|
||||
const currentState = await this.getAuthState();
|
||||
console.log(
|
||||
'[AuthStateManager] Current state before clearing:',
|
||||
currentState,
|
||||
);
|
||||
|
||||
await AuthStateManager.context.globalState.update(
|
||||
AuthStateManager.AUTH_STATE_KEY,
|
||||
undefined,
|
||||
);
|
||||
console.log('[AuthStateManager] Auth state cleared');
|
||||
|
||||
// Verify the state was cleared
|
||||
const newState = await this.getAuthState();
|
||||
console.log('[AuthStateManager] State after clearing:', newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auth state
|
||||
*/
|
||||
private async getAuthState(): Promise<AuthState | undefined> {
|
||||
// Ensure we have a valid context
|
||||
if (!AuthStateManager.context) {
|
||||
console.log(
|
||||
'[AuthStateManager] No context available for getting auth state',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const a = AuthStateManager.context.globalState.get<AuthState>(
|
||||
AuthStateManager.AUTH_STATE_KEY,
|
||||
);
|
||||
console.log('[AuthStateManager] Auth state:', a);
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth state info for debugging
|
||||
*/
|
||||
async getAuthInfo(): Promise<string> {
|
||||
const state = await this.getAuthState();
|
||||
if (!state) {
|
||||
return 'No cached auth';
|
||||
}
|
||||
|
||||
const age = Math.floor((Date.now() - state.timestamp) / 1000 / 60);
|
||||
return `Auth cached ${age}m ago, method: ${state.authMethod}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type * as vscode from 'vscode';
|
||||
import type { ChatMessage } from './qwenAgentManager.js';
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export class ConversationStore {
|
||||
private context: vscode.ExtensionContext;
|
||||
private currentConversationId: string | null = null;
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async createConversation(title: string = 'New Chat'): Promise<Conversation> {
|
||||
const conversation: Conversation = {
|
||||
id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
title,
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const conversations = await this.getAllConversations();
|
||||
conversations.push(conversation);
|
||||
await this.context.globalState.update('conversations', conversations);
|
||||
|
||||
this.currentConversationId = conversation.id;
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async getAllConversations(): Promise<Conversation[]> {
|
||||
return this.context.globalState.get<Conversation[]>('conversations', []);
|
||||
}
|
||||
|
||||
async getConversation(id: string): Promise<Conversation | null> {
|
||||
const conversations = await this.getAllConversations();
|
||||
return conversations.find((c) => c.id === id) || null;
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
conversationId: string,
|
||||
message: ChatMessage,
|
||||
): Promise<void> {
|
||||
const conversations = await this.getAllConversations();
|
||||
const conversation = conversations.find((c) => c.id === conversationId);
|
||||
|
||||
if (conversation) {
|
||||
conversation.messages.push(message);
|
||||
conversation.updatedAt = Date.now();
|
||||
await this.context.globalState.update('conversations', conversations);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversation(id: string): Promise<void> {
|
||||
const conversations = await this.getAllConversations();
|
||||
const filtered = conversations.filter((c) => c.id !== id);
|
||||
await this.context.globalState.update('conversations', filtered);
|
||||
|
||||
if (this.currentConversationId === id) {
|
||||
this.currentConversationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.currentConversationId;
|
||||
}
|
||||
|
||||
setCurrentConversationId(id: string): void {
|
||||
this.currentConversationId = id;
|
||||
}
|
||||
}
|
||||
1411
packages/vscode-ide-companion/src/services/qwenAgentManager.ts
Normal file
1411
packages/vscode-ide-companion/src/services/qwenAgentManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Qwen Connection Handler
|
||||
*
|
||||
* Handles Qwen Agent connection establishment, authentication, and session creation
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { AcpConnection } from './acpConnection.js';
|
||||
import type { QwenSessionReader } from '../services/qwenSessionReader.js';
|
||||
import type { AuthStateManager } from '../services/authStateManager.js';
|
||||
import {
|
||||
CliVersionManager,
|
||||
MIN_CLI_VERSION_FOR_SESSION_METHODS,
|
||||
} from '../cli/cliVersionManager.js';
|
||||
import { CliContextManager } from '../cli/cliContextManager.js';
|
||||
import { authMethod } from '../types/acpTypes.js';
|
||||
|
||||
/**
|
||||
* Qwen Connection Handler class
|
||||
* Handles connection, authentication, and session initialization
|
||||
*/
|
||||
export class QwenConnectionHandler {
|
||||
/**
|
||||
* Connect to Qwen service and establish session
|
||||
*
|
||||
* @param connection - ACP connection instance
|
||||
* @param sessionReader - Session reader instance
|
||||
* @param workingDir - Working directory
|
||||
* @param authStateManager - Authentication state manager (optional)
|
||||
* @param cliPath - CLI path (optional, if provided will override the path in configuration)
|
||||
*/
|
||||
async connect(
|
||||
connection: AcpConnection,
|
||||
sessionReader: QwenSessionReader,
|
||||
workingDir: string,
|
||||
authStateManager?: AuthStateManager,
|
||||
cliPath?: string,
|
||||
): Promise<void> {
|
||||
const connectId = Date.now();
|
||||
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
|
||||
|
||||
// Check CLI version and features
|
||||
const cliVersionManager = CliVersionManager.getInstance();
|
||||
const versionInfo = await cliVersionManager.detectCliVersion();
|
||||
console.log('[QwenAgentManager] CLI version info:', versionInfo);
|
||||
|
||||
// Store CLI context
|
||||
const cliContextManager = CliContextManager.getInstance();
|
||||
cliContextManager.setCurrentVersionInfo(versionInfo);
|
||||
|
||||
// Show warning if CLI version is below minimum requirement
|
||||
if (!versionInfo.isSupported) {
|
||||
// Wait to determine release version number
|
||||
vscode.window.showWarningMessage(
|
||||
`Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`,
|
||||
);
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||
// Use the provided CLI path if available, otherwise use the configured path
|
||||
const effectiveCliPath =
|
||||
cliPath || config.get<string>('qwen.cliPath', 'qwen');
|
||||
|
||||
// Build extra CLI arguments (only essential parameters)
|
||||
const extraArgs: string[] = [];
|
||||
|
||||
await connection.connect(effectiveCliPath, workingDir, extraArgs);
|
||||
|
||||
// Check if we have valid cached authentication
|
||||
if (authStateManager) {
|
||||
console.log('[QwenAgentManager] Checking for cached authentication...');
|
||||
console.log('[QwenAgentManager] Working dir:', workingDir);
|
||||
console.log('[QwenAgentManager] Auth method:', authMethod);
|
||||
|
||||
const hasValidAuth = await authStateManager.hasValidAuth(
|
||||
workingDir,
|
||||
authMethod,
|
||||
);
|
||||
console.log('[QwenAgentManager] Has valid auth:', hasValidAuth);
|
||||
} else {
|
||||
console.log('[QwenAgentManager] No authStateManager provided');
|
||||
}
|
||||
|
||||
// Try to restore existing session or create new session
|
||||
// Note: Auto-restore on connect is disabled to avoid surprising loads
|
||||
// when user opens a "New Chat" tab. Restoration is now an explicit action
|
||||
// (session selector → session/load) or handled by higher-level flows.
|
||||
const sessionRestored = false;
|
||||
|
||||
// Create new session if unable to restore
|
||||
if (!sessionRestored) {
|
||||
console.log(
|
||||
'[QwenAgentManager] no sessionRestored, Creating new session...',
|
||||
);
|
||||
|
||||
// Check if we have valid cached authentication
|
||||
let hasValidAuth = false;
|
||||
if (authStateManager) {
|
||||
hasValidAuth = await authStateManager.hasValidAuth(
|
||||
workingDir,
|
||||
authMethod,
|
||||
);
|
||||
}
|
||||
|
||||
// Only authenticate if we don't have valid cached auth
|
||||
if (!hasValidAuth) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Authenticating before creating session...',
|
||||
);
|
||||
try {
|
||||
await connection.authenticate(authMethod);
|
||||
console.log('[QwenAgentManager] Authentication successful');
|
||||
|
||||
// Save auth state
|
||||
if (authStateManager) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving auth state after successful authentication',
|
||||
);
|
||||
console.log('[QwenAgentManager] Working dir for save:', workingDir);
|
||||
console.log('[QwenAgentManager] Auth method for save:', authMethod);
|
||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
||||
console.log('[QwenAgentManager] Auth state save completed');
|
||||
}
|
||||
} catch (authError) {
|
||||
console.error('[QwenAgentManager] Authentication failed:', authError);
|
||||
// Clear potentially invalid cache
|
||||
if (authStateManager) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Clearing auth cache due to authentication failure',
|
||||
);
|
||||
await authStateManager.clearAuthState();
|
||||
}
|
||||
throw authError;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'[QwenAgentManager] Skipping authentication - using valid cached auth',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
'[QwenAgentManager] Creating new session after authentication...',
|
||||
);
|
||||
await this.newSessionWithRetry(
|
||||
connection,
|
||||
workingDir,
|
||||
3,
|
||||
authMethod,
|
||||
authStateManager,
|
||||
);
|
||||
console.log('[QwenAgentManager] New session created successfully');
|
||||
|
||||
// Ensure auth state is saved (prevent repeated authentication)
|
||||
if (authStateManager) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving auth state after successful session creation',
|
||||
);
|
||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
||||
}
|
||||
} catch (sessionError) {
|
||||
console.log(`\n⚠️ [SESSION FAILED] newSessionWithRetry threw error\n`);
|
||||
console.log(`[QwenAgentManager] Error details:`, sessionError);
|
||||
|
||||
// Clear cache
|
||||
if (authStateManager) {
|
||||
console.log('[QwenAgentManager] Clearing auth cache due to failure');
|
||||
await authStateManager.clearAuthState();
|
||||
}
|
||||
|
||||
throw sessionError;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
|
||||
console.log(`========================================\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new session (with retry)
|
||||
*
|
||||
* @param connection - ACP connection instance
|
||||
* @param workingDir - Working directory
|
||||
* @param maxRetries - Maximum number of retries
|
||||
*/
|
||||
private async newSessionWithRetry(
|
||||
connection: AcpConnection,
|
||||
workingDir: string,
|
||||
maxRetries: number,
|
||||
authMethod: string,
|
||||
authStateManager?: AuthStateManager,
|
||||
): Promise<void> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(
|
||||
`[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`,
|
||||
);
|
||||
await connection.newSession(workingDir);
|
||||
console.log('[QwenAgentManager] Session created successfully');
|
||||
return;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[QwenAgentManager] Session creation attempt ${attempt} failed:`,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
// If Qwen reports that authentication is required, try to
|
||||
// authenticate on-the-fly once and retry without waiting.
|
||||
const requiresAuth =
|
||||
errorMessage.includes('Authentication required') ||
|
||||
errorMessage.includes('(code: -32000)');
|
||||
if (requiresAuth) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...',
|
||||
);
|
||||
try {
|
||||
await connection.authenticate(authMethod);
|
||||
if (authStateManager) {
|
||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
||||
}
|
||||
// Retry immediately after successful auth
|
||||
await connection.newSession(workingDir);
|
||||
console.log(
|
||||
'[QwenAgentManager] Session created successfully after auth',
|
||||
);
|
||||
return;
|
||||
} catch (authErr) {
|
||||
console.error(
|
||||
'[QwenAgentManager] Re-authentication failed:',
|
||||
authErr,
|
||||
);
|
||||
if (authStateManager) {
|
||||
await authStateManager.clearAuthState();
|
||||
}
|
||||
// Fall through to retry logic below
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(
|
||||
`Session creation failed after ${maxRetries} attempts: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
|
||||
console.log(`[QwenAgentManager] Retrying in ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
336
packages/vscode-ide-companion/src/services/qwenSessionManager.ts
Normal file
336
packages/vscode-ide-companion/src/services/qwenSessionManager.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import type { QwenSession, QwenMessage } from './qwenSessionReader.js';
|
||||
|
||||
/**
|
||||
* Qwen Session Manager
|
||||
*
|
||||
* This service provides direct filesystem access to save and load sessions
|
||||
* without relying on the CLI's ACP session/save method.
|
||||
*
|
||||
* Note: This is primarily used as a fallback mechanism when ACP methods are
|
||||
* unavailable or fail. In normal operation, ACP session/list and session/load
|
||||
* should be preferred for consistency with the CLI.
|
||||
*/
|
||||
export class QwenSessionManager {
|
||||
private qwenDir: string;
|
||||
|
||||
constructor() {
|
||||
this.qwenDir = path.join(os.homedir(), '.qwen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project hash (same as CLI)
|
||||
* Qwen CLI uses SHA256 hash of the project path
|
||||
*/
|
||||
private getProjectHash(workingDir: string): string {
|
||||
return crypto.createHash('sha256').update(workingDir).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session directory for a project
|
||||
*/
|
||||
private getSessionDir(workingDir: string): string {
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
return path.join(this.qwenDir, 'tmp', projectHash, 'chats');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new session ID
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current conversation as a checkpoint (matching CLI's /chat save format)
|
||||
* Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility
|
||||
*
|
||||
* @param messages - Current conversation messages
|
||||
* @param conversationId - Conversation ID (from VSCode extension)
|
||||
* @param sessionId - Session ID (from CLI tmp session file, optional)
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Checkpoint tag
|
||||
*/
|
||||
async saveCheckpoint(
|
||||
messages: QwenMessage[],
|
||||
conversationId: string,
|
||||
workingDir: string,
|
||||
sessionId?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
console.log('[QwenSessionManager] ===== SAVEPOINT START =====');
|
||||
console.log('[QwenSessionManager] Conversation ID:', conversationId);
|
||||
console.log(
|
||||
'[QwenSessionManager] Session ID:',
|
||||
sessionId || 'not provided',
|
||||
);
|
||||
console.log('[QwenSessionManager] Working dir:', workingDir);
|
||||
console.log('[QwenSessionManager] Message count:', messages.length);
|
||||
|
||||
// Get project directory (parent of chats directory)
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
console.log('[QwenSessionManager] Project hash:', projectHash);
|
||||
|
||||
const projectDir = path.join(this.qwenDir, 'tmp', projectHash);
|
||||
console.log('[QwenSessionManager] Project dir:', projectDir);
|
||||
|
||||
if (!fs.existsSync(projectDir)) {
|
||||
console.log('[QwenSessionManager] Creating project directory...');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
console.log('[QwenSessionManager] Directory created');
|
||||
} else {
|
||||
console.log('[QwenSessionManager] Project directory already exists');
|
||||
}
|
||||
|
||||
// Convert messages to checkpoint format (Gemini-style messages)
|
||||
console.log(
|
||||
'[QwenSessionManager] Converting messages to checkpoint format...',
|
||||
);
|
||||
const checkpointMessages = messages.map((msg, index) => {
|
||||
console.log(
|
||||
`[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`,
|
||||
);
|
||||
return {
|
||||
role: msg.type === 'user' ? 'user' : 'model',
|
||||
parts: [
|
||||
{
|
||||
text: msg.content,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[QwenSessionManager] Converted',
|
||||
checkpointMessages.length,
|
||||
'messages',
|
||||
);
|
||||
|
||||
const jsonContent = JSON.stringify(checkpointMessages, null, 2);
|
||||
console.log(
|
||||
'[QwenSessionManager] JSON content length:',
|
||||
jsonContent.length,
|
||||
);
|
||||
|
||||
// Save with conversationId as primary tag
|
||||
const convFilename = `checkpoint-${conversationId}.json`;
|
||||
const convFilePath = path.join(projectDir, convFilename);
|
||||
console.log(
|
||||
'[QwenSessionManager] Saving checkpoint with conversationId:',
|
||||
convFilePath,
|
||||
);
|
||||
fs.writeFileSync(convFilePath, jsonContent, 'utf-8');
|
||||
|
||||
// Also save with sessionId if provided (for compatibility with CLI session/load)
|
||||
if (sessionId) {
|
||||
const sessionFilename = `checkpoint-${sessionId}.json`;
|
||||
const sessionFilePath = path.join(projectDir, sessionFilename);
|
||||
console.log(
|
||||
'[QwenSessionManager] Also saving checkpoint with sessionId:',
|
||||
sessionFilePath,
|
||||
);
|
||||
fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8');
|
||||
}
|
||||
|
||||
// Verify primary file exists
|
||||
if (fs.existsSync(convFilePath)) {
|
||||
const stats = fs.statSync(convFilePath);
|
||||
console.log(
|
||||
'[QwenSessionManager] Primary checkpoint verified, size:',
|
||||
stats.size,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
'[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[QwenSessionManager] ===== CHECKPOINT SAVED =====');
|
||||
console.log('[QwenSessionManager] Primary path:', convFilePath);
|
||||
if (sessionId) {
|
||||
console.log(
|
||||
'[QwenSessionManager] Secondary path (sessionId):',
|
||||
path.join(projectDir, `checkpoint-${sessionId}.json`),
|
||||
);
|
||||
}
|
||||
return conversationId;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED =====');
|
||||
console.error('[QwenSessionManager] Error:', error);
|
||||
console.error(
|
||||
'[QwenSessionManager] Error stack:',
|
||||
error instanceof Error ? error.stack : 'N/A',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current conversation as a named session (checkpoint-like functionality)
|
||||
*
|
||||
* @param messages - Current conversation messages
|
||||
* @param sessionName - Name/tag for the saved session
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Session ID of the saved session
|
||||
*/
|
||||
async saveSession(
|
||||
messages: QwenMessage[],
|
||||
sessionName: string,
|
||||
workingDir: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Create session directory if it doesn't exist
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate session ID and filename using CLI's naming convention
|
||||
const sessionId = this.generateSessionId();
|
||||
const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars)
|
||||
const now = new Date();
|
||||
const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const isoTime = now
|
||||
.toISOString()
|
||||
.split('T')[1]
|
||||
.split(':')
|
||||
.slice(0, 2)
|
||||
.join('-'); // HH-MM
|
||||
const filename = `session-${isoDate}T${isoTime}-${shortId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
// Create session object
|
||||
const session: QwenSession = {
|
||||
sessionId,
|
||||
projectHash: this.getProjectHash(workingDir),
|
||||
startTime: messages[0]?.timestamp || new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
|
||||
// Save session to file
|
||||
fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf-8');
|
||||
|
||||
console.log(`[QwenSessionManager] Session saved: ${filePath}`);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to save session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved session by name
|
||||
*
|
||||
* @param sessionName - Name/tag of the session to load
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Loaded session or null if not found
|
||||
*/
|
||||
async loadSession(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
): Promise<QwenSession | null> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
const filename = `session-${sessionId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`[QwenSessionManager] Session file not found: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
|
||||
console.log(`[QwenSessionManager] Session loaded: ${filePath}`);
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to load session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved sessions
|
||||
*
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Array of session objects
|
||||
*/
|
||||
async listSessions(workingDir: string): Promise<QwenSession[]> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(sessionDir)
|
||||
.filter(
|
||||
(file) => file.startsWith('session-') && file.endsWith('.json'),
|
||||
);
|
||||
|
||||
const sessions: QwenSession[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(sessionDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
sessions.push(session);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[QwenSessionManager] Failed to read session file ${file}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last updated time (newest first)
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(),
|
||||
);
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to list sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved session
|
||||
*
|
||||
* @param sessionId - ID of the session to delete
|
||||
* @param workingDir - Current working directory
|
||||
* @returns True if deleted successfully, false otherwise
|
||||
*/
|
||||
async deleteSession(sessionId: string, workingDir: string): Promise<boolean> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
const filename = `session-${sessionId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`[QwenSessionManager] Session deleted: ${filePath}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to delete session:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
packages/vscode-ide-companion/src/services/qwenSessionReader.ts
Normal file
177
packages/vscode-ide-companion/src/services/qwenSessionReader.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
export interface QwenMessage {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'user' | 'qwen';
|
||||
content: string;
|
||||
thoughts?: unknown[];
|
||||
tokens?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cached: number;
|
||||
thoughts: number;
|
||||
tool: number;
|
||||
total: number;
|
||||
};
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface QwenSession {
|
||||
sessionId: string;
|
||||
projectHash: string;
|
||||
startTime: string;
|
||||
lastUpdated: string;
|
||||
messages: QwenMessage[];
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export class QwenSessionReader {
|
||||
private qwenDir: string;
|
||||
|
||||
constructor() {
|
||||
this.qwenDir = path.join(os.homedir(), '.qwen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session list (optional: current project only or all projects)
|
||||
*/
|
||||
async getAllSessions(
|
||||
workingDir?: string,
|
||||
allProjects: boolean = false,
|
||||
): Promise<QwenSession[]> {
|
||||
try {
|
||||
const sessions: QwenSession[] = [];
|
||||
|
||||
if (!allProjects && workingDir) {
|
||||
// Current project only
|
||||
const projectHash = await this.getProjectHash(workingDir);
|
||||
const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats');
|
||||
const projectSessions = await this.readSessionsFromDir(chatsDir);
|
||||
sessions.push(...projectSessions);
|
||||
} else {
|
||||
// All projects
|
||||
const tmpDir = path.join(this.qwenDir, 'tmp');
|
||||
if (!fs.existsSync(tmpDir)) {
|
||||
console.log('[QwenSessionReader] Tmp directory not found:', tmpDir);
|
||||
return [];
|
||||
}
|
||||
|
||||
const projectDirs = fs.readdirSync(tmpDir);
|
||||
for (const projectHash of projectDirs) {
|
||||
const chatsDir = path.join(tmpDir, projectHash, 'chats');
|
||||
const projectSessions = await this.readSessionsFromDir(chatsDir);
|
||||
sessions.push(...projectSessions);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last updated time
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(),
|
||||
);
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionReader] Failed to get sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all sessions from specified directory
|
||||
*/
|
||||
private async readSessionsFromDir(chatsDir: string): Promise<QwenSession[]> {
|
||||
const sessions: QwenSession[] = [];
|
||||
|
||||
if (!fs.existsSync(chatsDir)) {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter((f) => f.startsWith('session-') && f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
session.filePath = filePath;
|
||||
sessions.push(session);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[QwenSessionReader] Failed to read session file:',
|
||||
filePath,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details of specific session
|
||||
*/
|
||||
async getSession(
|
||||
sessionId: string,
|
||||
_workingDir?: string,
|
||||
): Promise<QwenSession | null> {
|
||||
// First try to find in all projects
|
||||
const sessions = await this.getAllSessions(undefined, true);
|
||||
return sessions.find((s) => s.sessionId === sessionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project hash (needs to be consistent with Qwen CLI)
|
||||
* Qwen CLI uses SHA256 hash of project path
|
||||
*/
|
||||
private async getProjectHash(workingDir: string): Promise<string> {
|
||||
const crypto = await import('crypto');
|
||||
return crypto.createHash('sha256').update(workingDir).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session title (based on first user message)
|
||||
*/
|
||||
getSessionTitle(session: QwenSession): string {
|
||||
const firstUserMessage = session.messages.find((m) => m.type === 'user');
|
||||
if (firstUserMessage) {
|
||||
// Extract first 50 characters as title
|
||||
return (
|
||||
firstUserMessage.content.substring(0, 50) +
|
||||
(firstUserMessage.content.length > 50 ? '...' : '')
|
||||
);
|
||||
}
|
||||
return 'Untitled Session';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session file
|
||||
*/
|
||||
async deleteSession(
|
||||
sessionId: string,
|
||||
_workingDir: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const session = await this.getSession(sessionId, _workingDir);
|
||||
if (session && session.filePath) {
|
||||
fs.unlinkSync(session.filePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionReader] Failed to delete session:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Qwen Session Update Handler
|
||||
*
|
||||
* Handles session updates from ACP and dispatches them to appropriate callbacks
|
||||
*/
|
||||
|
||||
import type { AcpSessionUpdate, ApprovalModeValue } from '../types/acpTypes.js';
|
||||
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
|
||||
|
||||
/**
|
||||
* Qwen Session Update Handler class
|
||||
* Processes various session update events and calls appropriate callbacks
|
||||
*/
|
||||
export class QwenSessionUpdateHandler {
|
||||
private callbacks: QwenAgentCallbacks;
|
||||
|
||||
constructor(callbacks: QwenAgentCallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update callbacks
|
||||
*
|
||||
* @param callbacks - New callback collection
|
||||
*/
|
||||
updateCallbacks(callbacks: QwenAgentCallbacks): void {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session update
|
||||
*
|
||||
* @param data - ACP session update data
|
||||
*/
|
||||
handleSessionUpdate(data: AcpSessionUpdate): void {
|
||||
const update = data.update;
|
||||
console.log(
|
||||
'[SessionUpdateHandler] Processing update type:',
|
||||
update.sessionUpdate,
|
||||
);
|
||||
|
||||
switch (update.sessionUpdate) {
|
||||
case 'user_message_chunk':
|
||||
if (update.content?.text && this.callbacks.onStreamChunk) {
|
||||
this.callbacks.onStreamChunk(update.content.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent_message_chunk':
|
||||
if (update.content?.text && this.callbacks.onStreamChunk) {
|
||||
this.callbacks.onStreamChunk(update.content.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent_thought_chunk':
|
||||
if (update.content?.text) {
|
||||
if (this.callbacks.onThoughtChunk) {
|
||||
this.callbacks.onThoughtChunk(update.content.text);
|
||||
} else if (this.callbacks.onStreamChunk) {
|
||||
// Fallback to regular stream processing
|
||||
console.log(
|
||||
'[SessionUpdateHandler] 🧠 Falling back to onStreamChunk',
|
||||
);
|
||||
this.callbacks.onStreamChunk(update.content.text);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_call': {
|
||||
// Handle new tool call
|
||||
if (this.callbacks.onToolCall && 'toolCallId' in update) {
|
||||
this.callbacks.onToolCall({
|
||||
toolCallId: update.toolCallId as string,
|
||||
kind: (update.kind as string) || undefined,
|
||||
title: (update.title as string) || undefined,
|
||||
status: (update.status as string) || undefined,
|
||||
rawInput: update.rawInput,
|
||||
content: update.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined,
|
||||
locations: update.locations as
|
||||
| Array<{ path: string; line?: number | null }>
|
||||
| undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_call_update': {
|
||||
if (this.callbacks.onToolCall && 'toolCallId' in update) {
|
||||
this.callbacks.onToolCall({
|
||||
toolCallId: update.toolCallId as string,
|
||||
kind: (update.kind as string) || undefined,
|
||||
title: (update.title as string) || undefined,
|
||||
status: (update.status as string) || undefined,
|
||||
rawInput: update.rawInput,
|
||||
content: update.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined,
|
||||
locations: update.locations as
|
||||
| Array<{ path: string; line?: number | null }>
|
||||
| undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plan': {
|
||||
if ('entries' in update) {
|
||||
const entries = update.entries as Array<{
|
||||
content: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>;
|
||||
|
||||
if (this.callbacks.onPlan) {
|
||||
this.callbacks.onPlan(entries);
|
||||
} else if (this.callbacks.onStreamChunk) {
|
||||
// Fallback to stream processing
|
||||
const planText =
|
||||
'\n📋 Plan:\n' +
|
||||
entries
|
||||
.map(
|
||||
(entry, i) =>
|
||||
`${i + 1}. [${entry.priority}] ${entry.content}`,
|
||||
)
|
||||
.join('\n');
|
||||
this.callbacks.onStreamChunk(planText);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'current_mode_update': {
|
||||
// Notify UI about mode change
|
||||
try {
|
||||
const modeId = (update as unknown as { modeId?: ApprovalModeValue })
|
||||
.modeId;
|
||||
if (modeId && this.callbacks.onModeChanged) {
|
||||
this.callbacks.onModeChanged(modeId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[SessionUpdateHandler] Failed to handle mode update',
|
||||
err,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('[QwenAgentManager] Unhandled session update type');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
203
packages/vscode-ide-companion/src/types/acpTypes.ts
Normal file
203
packages/vscode-ide-companion/src/types/acpTypes.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const JSONRPC_VERSION = '2.0' as const;
|
||||
export const authMethod = 'qwen-oauth';
|
||||
|
||||
export interface AcpRequest {
|
||||
jsonrpc: typeof JSONRPC_VERSION;
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface AcpResponse {
|
||||
jsonrpc: typeof JSONRPC_VERSION;
|
||||
id: number;
|
||||
result?: unknown;
|
||||
capabilities?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AcpNotification {
|
||||
jsonrpc: typeof JSONRPC_VERSION;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface BaseSessionUpdate {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Content block type (simplified version, use schema.ContentBlock for validation)
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image';
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
export interface UserMessageChunkUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'user_message_chunk';
|
||||
content: ContentBlock;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentMessageChunkUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk';
|
||||
content: ContentBlock;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentThoughtChunkUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'agent_thought_chunk';
|
||||
content: ContentBlock;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCallUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'tool_call';
|
||||
toolCallId: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
title: string;
|
||||
kind:
|
||||
| 'read'
|
||||
| 'edit'
|
||||
| 'execute'
|
||||
| 'delete'
|
||||
| 'move'
|
||||
| 'search'
|
||||
| 'fetch'
|
||||
| 'think'
|
||||
| 'other';
|
||||
rawInput?: unknown;
|
||||
content?: Array<{
|
||||
type: 'content' | 'diff';
|
||||
content?: {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCallStatusUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update';
|
||||
toolCallId: string;
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
title?: string;
|
||||
kind?: string;
|
||||
rawInput?: unknown;
|
||||
content?: Array<{
|
||||
type: 'content' | 'diff';
|
||||
content?: {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'plan';
|
||||
entries: Array<{
|
||||
content: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
|
||||
export {
|
||||
ApprovalMode,
|
||||
APPROVAL_MODE_MAP,
|
||||
APPROVAL_MODE_INFO,
|
||||
getApprovalModeInfoFromString,
|
||||
} from './approvalModeTypes.js';
|
||||
|
||||
// Cyclic next-mode mapping used by UI toggles and other consumers
|
||||
export const NEXT_APPROVAL_MODE: {
|
||||
[k in ApprovalModeValue]: ApprovalModeValue;
|
||||
} = {
|
||||
// Hide "plan" from the public toggle sequence for now
|
||||
// Cycle: default -> auto-edit -> yolo -> default
|
||||
default: 'auto-edit',
|
||||
'auto-edit': 'yolo',
|
||||
plan: 'yolo',
|
||||
yolo: 'default',
|
||||
};
|
||||
|
||||
// Current mode update (sent by agent when mode changes)
|
||||
export interface CurrentModeUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'current_mode_update';
|
||||
modeId: ApprovalModeValue;
|
||||
};
|
||||
}
|
||||
|
||||
export type AcpSessionUpdate =
|
||||
| UserMessageChunkUpdate
|
||||
| AgentMessageChunkUpdate
|
||||
| AgentThoughtChunkUpdate
|
||||
| ToolCallUpdate
|
||||
| ToolCallStatusUpdate
|
||||
| PlanUpdate
|
||||
| CurrentModeUpdate;
|
||||
|
||||
// Permission request (simplified version, use schema.RequestPermissionRequest for validation)
|
||||
export interface AcpPermissionRequest {
|
||||
sessionId: string;
|
||||
options: Array<{
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
|
||||
}>;
|
||||
toolCall: {
|
||||
toolCallId: string;
|
||||
rawInput?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
title?: string;
|
||||
kind?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AcpMessage =
|
||||
| AcpRequest
|
||||
| AcpNotification
|
||||
| AcpResponse
|
||||
| AcpSessionUpdate;
|
||||
79
packages/vscode-ide-companion/src/types/approvalModeTypes.ts
Normal file
79
packages/vscode-ide-companion/src/types/approvalModeTypes.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enum for approval modes with UI-friendly labels
|
||||
* Represents the different approval modes available in the ACP protocol
|
||||
* with their corresponding user-facing display names
|
||||
*/
|
||||
export enum ApprovalMode {
|
||||
PLAN = 'plan',
|
||||
DEFAULT = 'default',
|
||||
AUTO_EDIT = 'auto-edit',
|
||||
YOLO = 'yolo',
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from string values to enum values for runtime conversion
|
||||
*/
|
||||
export const APPROVAL_MODE_MAP: Record<string, ApprovalMode> = {
|
||||
plan: ApprovalMode.PLAN,
|
||||
default: ApprovalMode.DEFAULT,
|
||||
'auto-edit': ApprovalMode.AUTO_EDIT,
|
||||
yolo: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
/**
|
||||
* UI display information for each approval mode
|
||||
*/
|
||||
export const APPROVAL_MODE_INFO: Record<
|
||||
ApprovalMode,
|
||||
{
|
||||
label: string;
|
||||
title: string;
|
||||
iconType?: 'edit' | 'auto' | 'plan' | 'yolo';
|
||||
}
|
||||
> = {
|
||||
[ApprovalMode.PLAN]: {
|
||||
label: 'Plan mode',
|
||||
title: 'Qwen will plan before executing. Click to switch modes.',
|
||||
iconType: 'plan',
|
||||
},
|
||||
[ApprovalMode.DEFAULT]: {
|
||||
label: 'Ask before edits',
|
||||
title: 'Qwen will ask before each edit. Click to switch modes.',
|
||||
iconType: 'edit',
|
||||
},
|
||||
[ApprovalMode.AUTO_EDIT]: {
|
||||
label: 'Edit automatically',
|
||||
title: 'Qwen will edit files automatically. Click to switch modes.',
|
||||
iconType: 'auto',
|
||||
},
|
||||
[ApprovalMode.YOLO]: {
|
||||
label: 'YOLO',
|
||||
title: 'Automatically approve all tools. Click to switch modes.',
|
||||
iconType: 'yolo',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI display information for an approval mode from string value
|
||||
*/
|
||||
export function getApprovalModeInfoFromString(mode: string): {
|
||||
label: string;
|
||||
title: string;
|
||||
iconType?: 'edit' | 'auto' | 'plan' | 'yolo';
|
||||
} {
|
||||
const enumValue = APPROVAL_MODE_MAP[mode];
|
||||
if (enumValue !== undefined) {
|
||||
return APPROVAL_MODE_INFO[enumValue];
|
||||
}
|
||||
return {
|
||||
label: 'Unknown mode',
|
||||
title: 'Unknown edit mode',
|
||||
iconType: undefined,
|
||||
};
|
||||
}
|
||||
73
packages/vscode-ide-companion/src/types/chatTypes.ts
Normal file
73
packages/vscode-ide-companion/src/types/chatTypes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { AcpPermissionRequest, ApprovalModeValue } from './acpTypes.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PlanEntry {
|
||||
content: string;
|
||||
priority?: 'high' | 'medium' | 'low';
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
export interface ToolCallUpdateData {
|
||||
toolCallId: string;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
rawInput?: unknown;
|
||||
content?: Array<Record<string, unknown>>;
|
||||
locations?: Array<{ path: string; line?: number | null }>;
|
||||
}
|
||||
|
||||
export interface QwenAgentCallbacks {
|
||||
onMessage?: (message: ChatMessage) => void;
|
||||
onStreamChunk?: (chunk: string) => void;
|
||||
onThoughtChunk?: (chunk: string) => void;
|
||||
onToolCall?: (update: ToolCallUpdateData) => void;
|
||||
onPlan?: (entries: PlanEntry[]) => void;
|
||||
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
|
||||
onEndTurn?: () => void;
|
||||
onModeInfo?: (info: {
|
||||
currentModeId?: ApprovalModeValue;
|
||||
availableModes?: Array<{
|
||||
id: ApprovalModeValue;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
}) => void;
|
||||
onModeChanged?: (modeId: ApprovalModeValue) => void;
|
||||
}
|
||||
|
||||
export interface ToolCallUpdate {
|
||||
type: 'tool_call' | 'tool_call_update';
|
||||
toolCallId: string;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
rawInput?: unknown;
|
||||
content?: Array<{
|
||||
type: 'content' | 'diff';
|
||||
content?: {
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
timestamp?: number; // Add timestamp field for message ordering
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export interface CompletionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info';
|
||||
// Value inserted into the input when selected (e.g., filename or command)
|
||||
value?: string;
|
||||
// Optional full path for files (used to build @filename -> full path mapping)
|
||||
path?: string;
|
||||
}
|
||||
31
packages/vscode-ide-companion/src/types/connectionTypes.ts
Normal file
31
packages/vscode-ide-companion/src/types/connectionTypes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import type { AcpSessionUpdate, AcpPermissionRequest } from './acpTypes.js';
|
||||
|
||||
export interface PendingRequest<T = unknown> {
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
method: string;
|
||||
}
|
||||
|
||||
export interface AcpConnectionCallbacks {
|
||||
onSessionUpdate: (data: AcpSessionUpdate) => void;
|
||||
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
||||
optionId: string;
|
||||
}>;
|
||||
onEndTurn: () => void;
|
||||
}
|
||||
|
||||
export interface AcpConnectionState {
|
||||
child: ChildProcess | null;
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>;
|
||||
nextRequestId: number;
|
||||
sessionId: string | null;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
116
packages/vscode-ide-companion/src/utils/editorGroupUtils.ts
Normal file
116
packages/vscode-ide-companion/src/utils/editorGroupUtils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { openChatCommand } from '../commands/index.js';
|
||||
|
||||
/**
|
||||
* Find the editor group immediately to the left of the Qwen chat webview.
|
||||
* - If the chat webview group is the leftmost group, returns undefined.
|
||||
* - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'.
|
||||
*/
|
||||
export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined {
|
||||
try {
|
||||
const groups = vscode.window.tabGroups.all;
|
||||
|
||||
// Locate the group that contains our chat webview
|
||||
const webviewGroup = groups.find((group) =>
|
||||
group.tabs.some((tab) => {
|
||||
const input: unknown = (tab as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
return (
|
||||
isWebviewInput(input) &&
|
||||
input.viewType === 'mainThreadWebview-qwenCode.chat'
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!webviewGroup) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Among all groups to the left (smaller viewColumn), choose the one with
|
||||
// the largest viewColumn value (i.e. the immediate neighbor on the left).
|
||||
let candidate:
|
||||
| { group: vscode.TabGroup; viewColumn: vscode.ViewColumn }
|
||||
| undefined;
|
||||
for (const g of groups) {
|
||||
if (g.viewColumn < webviewGroup.viewColumn) {
|
||||
if (!candidate || g.viewColumn > candidate.viewColumn) {
|
||||
candidate = { group: g, viewColumn: g.viewColumn };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidate?.viewColumn;
|
||||
} catch (_err) {
|
||||
// Best-effort only; fall back to default behavior if anything goes wrong
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* - If the chat webview cannot be located, returns undefined.
|
||||
*/
|
||||
export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
vscode.ViewColumn | undefined
|
||||
> {
|
||||
// First try to find an existing left neighbor
|
||||
const existing = findLeftGroupOfChatWebview();
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Locate the chat webview group
|
||||
const groups = vscode.window.tabGroups.all;
|
||||
const webviewGroup = groups.find((group) =>
|
||||
group.tabs.some((tab) => {
|
||||
const input: unknown = (tab as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
return (
|
||||
isWebviewInput(input) &&
|
||||
input.viewType === 'mainThreadWebview-qwenCode.chat'
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!webviewGroup) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousChatColumn = webviewGroup.viewColumn;
|
||||
|
||||
// Make the chat group active by revealing the panel
|
||||
try {
|
||||
await vscode.commands.executeCommand(openChatCommand);
|
||||
} catch {
|
||||
// Best-effort; continue even if this fails
|
||||
}
|
||||
|
||||
// Create a new group to the left of the chat group
|
||||
try {
|
||||
await vscode.commands.executeCommand('workbench.action.newGroupLeft');
|
||||
} catch {
|
||||
// If we fail to create a group, fall back to default behavior
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Restore focus to chat (optional), so we don't disturb user focus
|
||||
try {
|
||||
await vscode.commands.executeCommand(openChatCommand);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// The new left group's column equals the chat's previous column
|
||||
return previousChatColumn;
|
||||
}
|
||||
749
packages/vscode-ide-companion/src/webview/App.tsx
Normal file
749
packages/vscode-ide-companion/src/webview/App.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import { useVSCode } from './hooks/useVSCode.js';
|
||||
import { useSessionManagement } from './hooks/session/useSessionManagement.js';
|
||||
import { useFileContext } from './hooks/file/useFileContext.js';
|
||||
import { useMessageHandling } from './hooks/message/useMessageHandling.js';
|
||||
import { useToolCalls } from './hooks/useToolCalls.js';
|
||||
import { useWebViewMessages } from './hooks/useWebViewMessages.js';
|
||||
import { useMessageSubmit } from './hooks/useMessageSubmit.js';
|
||||
import type {
|
||||
PermissionOption,
|
||||
ToolCall as PermissionToolCall,
|
||||
} from './components/PermissionDrawer/PermissionRequest.js';
|
||||
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 { EmptyState } from './components/layout/EmptyState.js';
|
||||
import { type CompletionItem } from '../types/completionItemTypes.js';
|
||||
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
||||
import { ChatHeader } from './components/layout/ChatHeader.js';
|
||||
import {
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ThinkingMessage,
|
||||
WaitingMessage,
|
||||
InterruptedMessage,
|
||||
} from './components/messages/index.js';
|
||||
import { InputForm } from './components/layout/InputForm.js';
|
||||
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/acpTypes.js';
|
||||
import type { PlanEntry } from '../types/chatTypes.js';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const vscode = useVSCode();
|
||||
|
||||
// Core hooks
|
||||
const sessionManagement = useSessionManagement(vscode);
|
||||
const fileContext = useFileContext(vscode);
|
||||
const messageHandling = useMessageHandling();
|
||||
const {
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
} = useToolCalls();
|
||||
|
||||
// UI state
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [permissionRequest, setPermissionRequest] = useState<{
|
||||
options: PermissionOption[];
|
||||
toolCall: PermissionToolCall;
|
||||
} | null>(null);
|
||||
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
// Scroll container for message list; used to keep the view anchored to the latest content
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
const inputFieldRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
const [editMode, setEditMode] = useState<ApprovalModeValue>(
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
const [thinkingEnabled, setThinkingEnabled] = useState(false);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
// When true, do NOT auto-attach the active editor file/selection to message context
|
||||
const [skipAutoActiveContext, setSkipAutoActiveContext] = useState(false);
|
||||
|
||||
// Completion system
|
||||
const getCompletionItems = React.useCallback(
|
||||
async (trigger: '@' | '/', query: string): Promise<CompletionItem[]> => {
|
||||
if (trigger === '@') {
|
||||
if (!fileContext.hasRequestedFiles) {
|
||||
fileContext.requestWorkspaceFiles();
|
||||
}
|
||||
|
||||
const fileIcon = <FileIcon />;
|
||||
const allItems: CompletionItem[] = fileContext.workspaceFiles.map(
|
||||
(file) => ({
|
||||
id: file.id,
|
||||
label: file.label,
|
||||
description: file.description,
|
||||
type: 'file' as const,
|
||||
icon: fileIcon,
|
||||
// Insert filename after @, keep path for mapping
|
||||
value: file.label,
|
||||
path: file.path,
|
||||
}),
|
||||
);
|
||||
|
||||
if (query && query.length >= 1) {
|
||||
fileContext.requestWorkspaceFiles(query);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allItems.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(lowerQuery) ||
|
||||
(item.description &&
|
||||
item.description.toLowerCase().includes(lowerQuery)),
|
||||
);
|
||||
}
|
||||
|
||||
// If first time and still loading, show a placeholder
|
||||
if (allItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: 'loading-files',
|
||||
label: 'Searching files…',
|
||||
description: 'Type to filter, or wait a moment…',
|
||||
type: 'info' as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return allItems;
|
||||
} else {
|
||||
// Handle slash commands
|
||||
const commands: CompletionItem[] = [
|
||||
{
|
||||
id: 'login',
|
||||
label: '/login',
|
||||
description: 'Login to Qwen Code',
|
||||
type: 'command',
|
||||
icon: <UserIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
return commands.filter((cmd) =>
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
}
|
||||
},
|
||||
[fileContext],
|
||||
);
|
||||
|
||||
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
|
||||
|
||||
// When workspace files update while menu open for @, refresh items so the first @ shows the list
|
||||
// Note: Avoid depending on the entire `completion` object here, since its identity
|
||||
// changes on every render which would retrigger this effect and can cause a refresh loop.
|
||||
useEffect(() => {
|
||||
if (completion.isOpen && completion.triggerChar === '@') {
|
||||
// Only refresh items; do not change other completion state to avoid re-renders loops
|
||||
completion.refreshCompletion();
|
||||
}
|
||||
// Only re-run when the actual data source changes, not on every render
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]);
|
||||
|
||||
// Message submission
|
||||
const handleSubmit = useMessageSubmit({
|
||||
inputText,
|
||||
setInputText,
|
||||
messageHandling,
|
||||
fileContext,
|
||||
skipAutoActiveContext,
|
||||
vscode,
|
||||
inputFieldRef,
|
||||
isStreaming: messageHandling.isStreaming,
|
||||
});
|
||||
|
||||
// Handle cancel/stop from the input bar
|
||||
// Emit a cancel to the extension and immediately reflect interruption locally.
|
||||
const handleCancel = useCallback(() => {
|
||||
if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) {
|
||||
// Proactively end local states and add an 'Interrupted' line
|
||||
try {
|
||||
messageHandling.endStreaming?.();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
try {
|
||||
messageHandling.clearWaitingForResponse?.();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
messageHandling.addMessage({
|
||||
role: 'assistant',
|
||||
content: 'Interrupted',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
// Notify extension/agent to cancel server-side work
|
||||
vscode.postMessage({
|
||||
type: 'cancelStreaming',
|
||||
data: {},
|
||||
});
|
||||
}, [messageHandling, vscode]);
|
||||
|
||||
// Message handling
|
||||
useWebViewMessages({
|
||||
sessionManagement,
|
||||
fileContext,
|
||||
messageHandling,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
setPlanEntries,
|
||||
handlePermissionRequest: setPermissionRequest,
|
||||
inputFieldRef,
|
||||
setInputText,
|
||||
setEditMode,
|
||||
});
|
||||
|
||||
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
||||
// but don't interrupt the user if they scrolled up.
|
||||
// We track whether the user is currently "pinned" to the bottom (near the end).
|
||||
const [pinnedToBottom, setPinnedToBottom] = useState(true);
|
||||
const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 });
|
||||
|
||||
// Observe scroll position to know if user has scrolled away from the bottom.
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
// Use a small threshold so slight deltas don't flip the state.
|
||||
// Note: there's extra bottom padding for the input area, so keep this a bit generous.
|
||||
const threshold = 80; // px tolerance
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||||
setPinnedToBottom(distanceFromBottom <= threshold);
|
||||
};
|
||||
|
||||
// Initialize once mounted so first render is correct
|
||||
onScroll();
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
// When content changes, if the user is pinned to bottom, keep it anchored there.
|
||||
// Only smooth-scroll when new items are appended; do not smooth for streaming chunk updates.
|
||||
useLayoutEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect whether new items were appended (vs. streaming chunk updates)
|
||||
const prev = prevCountsRef.current;
|
||||
const newMsg = messageHandling.messages.length > prev.msgLen;
|
||||
const newInProg = inProgressToolCalls.length > prev.inProgLen;
|
||||
const newDone = completedToolCalls.length > prev.doneLen;
|
||||
prevCountsRef.current = {
|
||||
msgLen: messageHandling.messages.length,
|
||||
inProgLen: inProgressToolCalls.length,
|
||||
doneLen: completedToolCalls.length,
|
||||
};
|
||||
|
||||
if (!pinnedToBottom) {
|
||||
// Do nothing if user scrolled away; avoid stealing scroll.
|
||||
return;
|
||||
}
|
||||
|
||||
const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks
|
||||
|
||||
// Anchor to the bottom on next frame to avoid layout thrash.
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const top = container.scrollHeight - container.clientHeight;
|
||||
// Use scrollTo to avoid cross-context issues with scrollIntoView.
|
||||
container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [
|
||||
pinnedToBottom,
|
||||
messageHandling.messages,
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
messageHandling.isWaitingForResponse,
|
||||
messageHandling.loadingMessage,
|
||||
messageHandling.isStreaming,
|
||||
planEntries,
|
||||
]);
|
||||
|
||||
// When the last rendered item resizes (e.g., images/code blocks load/expand),
|
||||
// if we're pinned to bottom, keep it anchored there.
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
const endEl = messagesEndRef.current;
|
||||
if (!container || !endEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastItem = endEl.previousElementSibling as HTMLElement | null;
|
||||
if (!lastItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!pinnedToBottom) {
|
||||
return;
|
||||
}
|
||||
// Defer to next frame to avoid thrash during rapid size changes
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const top = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTo({ top });
|
||||
});
|
||||
});
|
||||
ro.observe(lastItem);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [
|
||||
pinnedToBottom,
|
||||
messageHandling.messages,
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
]);
|
||||
|
||||
// Handle permission response
|
||||
const handlePermissionResponse = useCallback(
|
||||
(optionId: string) => {
|
||||
// Forward the selected optionId directly to extension as ACP permission response
|
||||
// Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc.
|
||||
vscode.postMessage({
|
||||
type: 'permissionResponse',
|
||||
data: { optionId },
|
||||
});
|
||||
setPermissionRequest(null);
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
// Handle completion selection
|
||||
const handleCompletionSelect = useCallback(
|
||||
(item: CompletionItem) => {
|
||||
// Handle completion selection by inserting the value into the input field
|
||||
const inputElement = inputFieldRef.current;
|
||||
if (!inputElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore info items (placeholders like "Searching files…")
|
||||
if (item.type === 'info') {
|
||||
completion.closeCompletion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Slash commands can execute immediately
|
||||
if (item.type === 'command') {
|
||||
const command = (item.label || '').trim();
|
||||
if (command === '/login') {
|
||||
vscode.postMessage({ type: 'login', data: {} });
|
||||
completion.closeCompletion();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If selecting a file, add @filename -> fullpath mapping
|
||||
if (item.type === 'file' && item.value && item.path) {
|
||||
try {
|
||||
fileContext.addFileReference(item.value, item.path);
|
||||
} catch (err) {
|
||||
console.warn('[App] addFileReference failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Current text and cursor
|
||||
const text = inputElement.textContent || '';
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Compute total text offset for contentEditable
|
||||
let cursorPos = text.length;
|
||||
if (range.startContainer === inputElement) {
|
||||
const childIndex = range.startOffset;
|
||||
let offset = 0;
|
||||
for (
|
||||
let i = 0;
|
||||
i < childIndex && i < inputElement.childNodes.length;
|
||||
i++
|
||||
) {
|
||||
offset += inputElement.childNodes[i].textContent?.length || 0;
|
||||
}
|
||||
cursorPos = offset || text.length;
|
||||
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
const walker = document.createTreeWalker(
|
||||
inputElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
);
|
||||
let offset = 0;
|
||||
let found = false;
|
||||
let node: Node | null = walker.nextNode();
|
||||
while (node) {
|
||||
if (node === range.startContainer) {
|
||||
offset += range.startOffset;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
offset += node.textContent?.length || 0;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
cursorPos = found ? offset : text.length;
|
||||
}
|
||||
|
||||
// Replace from trigger to cursor with selected value
|
||||
const textBeforeCursor = text.substring(0, cursorPos);
|
||||
const atPos = textBeforeCursor.lastIndexOf('@');
|
||||
const slashPos = textBeforeCursor.lastIndexOf('/');
|
||||
const triggerPos = Math.max(atPos, slashPos);
|
||||
|
||||
if (triggerPos >= 0) {
|
||||
const insertValue =
|
||||
typeof item.value === 'string' ? item.value : String(item.label);
|
||||
const newText =
|
||||
text.substring(0, triggerPos + 1) + // keep the trigger symbol
|
||||
insertValue +
|
||||
' ' +
|
||||
text.substring(cursorPos);
|
||||
|
||||
// Update DOM and state, and move caret to end
|
||||
inputElement.textContent = newText;
|
||||
setInputText(newText);
|
||||
|
||||
const newRange = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
newRange.selectNodeContents(inputElement);
|
||||
newRange.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(newRange);
|
||||
}
|
||||
|
||||
// Close the completion menu
|
||||
completion.closeCompletion();
|
||||
},
|
||||
[completion, inputFieldRef, setInputText, fileContext, vscode],
|
||||
);
|
||||
|
||||
// Handle attach context click
|
||||
const handleAttachContextClick = useCallback(() => {
|
||||
// Open native file picker (different from '@' completion which searches workspace files)
|
||||
vscode.postMessage({
|
||||
type: 'attachFile',
|
||||
data: {},
|
||||
});
|
||||
}, [vscode]);
|
||||
|
||||
// Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default)
|
||||
const handleToggleEditMode = useCallback(() => {
|
||||
setEditMode((prev) => {
|
||||
const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev];
|
||||
|
||||
// Notify extension to set approval mode via ACP
|
||||
try {
|
||||
vscode.postMessage({
|
||||
type: 'setApprovalMode',
|
||||
data: { modeId: next },
|
||||
});
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [vscode]);
|
||||
|
||||
// Handle toggle thinking
|
||||
const handleToggleThinking = () => {
|
||||
setThinkingEnabled((prev) => !prev);
|
||||
};
|
||||
|
||||
// Create unified message array containing all types of messages and tool calls
|
||||
const allMessages = useMemo<
|
||||
Array<{
|
||||
type: 'message' | 'in-progress-tool-call' | 'completed-tool-call';
|
||||
data: TextMessage | ToolCallData;
|
||||
timestamp: number;
|
||||
}>
|
||||
>(() => {
|
||||
// Regular messages
|
||||
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||
type: 'message' as const,
|
||||
data: msg,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
|
||||
// In-progress tool calls
|
||||
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||
type: 'in-progress-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Completed tool calls
|
||||
const completedTools = completedToolCalls
|
||||
.filter(hasToolCallOutput)
|
||||
.map((toolCall) => ({
|
||||
type: 'completed-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
||||
return [...regularMessages, ...inProgressTools, ...completedTools].sort(
|
||||
(a, b) => (a.timestamp || 0) - (b.timestamp || 0),
|
||||
);
|
||||
}, [messageHandling.messages, inProgressToolCalls, completedToolCalls]);
|
||||
|
||||
console.log('[App] Rendering messages:', allMessages);
|
||||
|
||||
// Render all messages and tool calls
|
||||
const renderMessages = useCallback<() => React.ReactNode>(
|
||||
() =>
|
||||
allMessages.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'message': {
|
||||
const msg = item.data as TextMessage;
|
||||
const handleFileClick = (path: string): void => {
|
||||
vscode.postMessage({
|
||||
type: 'openFile',
|
||||
data: { path },
|
||||
});
|
||||
};
|
||||
|
||||
if (msg.role === 'thinking') {
|
||||
return (
|
||||
<ThinkingMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<UserMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
fileContext={msg.fileContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const content = (msg.content || '').trim();
|
||||
if (content === 'Interrupted' || content === 'Tool interrupted') {
|
||||
return (
|
||||
<InterruptedMessage key={`message-${index}`} text={content} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={`message-${index}`}
|
||||
content={content}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'in-progress-tool-call':
|
||||
case 'completed-tool-call': {
|
||||
const prev = allMessages[index - 1];
|
||||
const next = allMessages[index + 1];
|
||||
const isToolCallType = (
|
||||
x: unknown,
|
||||
): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } =>
|
||||
!!x &&
|
||||
typeof x === 'object' &&
|
||||
'type' in (x as Record<string, unknown>) &&
|
||||
((x as { type: string }).type === 'in-progress-tool-call' ||
|
||||
(x as { type: string }).type === 'completed-tool-call');
|
||||
const isFirst = !isToolCallType(prev);
|
||||
const isLast = !isToolCallType(next);
|
||||
return (
|
||||
<ToolCall
|
||||
key={`toolcall-${(item.data as ToolCallData).toolCallId}-${item.type}`}
|
||||
toolCall={item.data as ToolCallData}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
[allMessages, vscode],
|
||||
);
|
||||
|
||||
const hasContent =
|
||||
messageHandling.messages.length > 0 ||
|
||||
messageHandling.isStreaming ||
|
||||
inProgressToolCalls.length > 0 ||
|
||||
completedToolCalls.length > 0 ||
|
||||
planEntries.length > 0 ||
|
||||
allMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<SessionSelector
|
||||
visible={sessionManagement.showSessionSelector}
|
||||
sessions={sessionManagement.filteredSessions}
|
||||
currentSessionId={sessionManagement.currentSessionId}
|
||||
searchQuery={sessionManagement.sessionSearchQuery}
|
||||
onSearchChange={sessionManagement.setSessionSearchQuery}
|
||||
onSelectSession={(sessionId) => {
|
||||
sessionManagement.handleSwitchSession(sessionId);
|
||||
sessionManagement.setSessionSearchQuery('');
|
||||
}}
|
||||
onClose={() => sessionManagement.setShowSessionSelector(false)}
|
||||
hasMore={sessionManagement.hasMore}
|
||||
isLoading={sessionManagement.isLoading}
|
||||
onLoadMore={sessionManagement.handleLoadMoreSessions}
|
||||
/>
|
||||
|
||||
<ChatHeader
|
||||
currentSessionTitle={sessionManagement.currentSessionTitle}
|
||||
onLoadSessions={sessionManagement.handleLoadQwenSessions}
|
||||
onNewSession={sessionManagement.handleNewQwenSession}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
|
||||
>
|
||||
{!hasContent ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* Render all messages and tool calls */}
|
||||
{renderMessages()}
|
||||
{/* Flow-in persistent slot: keeps a small constant height so toggling */}
|
||||
{/* the waiting message doesn't change list height to zero. When */}
|
||||
{/* active, render the waiting message inline (not fixed). */}
|
||||
<div className="waiting-message-slot min-h-[28px]">
|
||||
{messageHandling.isWaitingForResponse &&
|
||||
messageHandling.loadingMessage && (
|
||||
<WaitingMessage
|
||||
loadingMessage={messageHandling.loadingMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InputForm
|
||||
inputText={inputText}
|
||||
inputFieldRef={inputFieldRef}
|
||||
isStreaming={messageHandling.isStreaming}
|
||||
isWaitingForResponse={messageHandling.isWaitingForResponse}
|
||||
isComposing={isComposing}
|
||||
editMode={editMode}
|
||||
thinkingEnabled={thinkingEnabled}
|
||||
activeFileName={fileContext.activeFileName}
|
||||
activeSelection={fileContext.activeSelection}
|
||||
skipAutoActiveContext={skipAutoActiveContext}
|
||||
onInputChange={setInputText}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onKeyDown={() => {}}
|
||||
onSubmit={handleSubmit.handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
onToggleEditMode={handleToggleEditMode}
|
||||
onToggleThinking={handleToggleThinking}
|
||||
onFocusActiveEditor={fileContext.focusActiveEditor}
|
||||
onToggleSkipAutoActiveContext={() =>
|
||||
setSkipAutoActiveContext((v) => !v)
|
||||
}
|
||||
onShowCommandMenu={async () => {
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
let position = { top: 0, left: 0 };
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
try {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rangeRect = range.getBoundingClientRect();
|
||||
if (rangeRect.top > 0 && rangeRect.left > 0) {
|
||||
position = {
|
||||
top: rangeRect.top,
|
||||
left: rangeRect.left,
|
||||
};
|
||||
} else {
|
||||
const inputRect =
|
||||
inputFieldRef.current.getBoundingClientRect();
|
||||
position = { top: inputRect.top, left: inputRect.left };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[App] Error getting cursor position:', error);
|
||||
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
||||
position = { top: inputRect.top, left: inputRect.left };
|
||||
}
|
||||
} else {
|
||||
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
||||
position = { top: inputRect.top, left: inputRect.left };
|
||||
}
|
||||
|
||||
await completion.openCompletion('/', '', position);
|
||||
}
|
||||
}}
|
||||
onAttachContext={handleAttachContextClick}
|
||||
completionIsOpen={completion.isOpen}
|
||||
completionItems={completion.items}
|
||||
onCompletionSelect={handleCompletionSelect}
|
||||
onCompletionClose={completion.closeCompletion}
|
||||
/>
|
||||
|
||||
{permissionRequest && (
|
||||
<PermissionDrawer
|
||||
isOpen={!!permissionRequest}
|
||||
options={permissionRequest.options}
|
||||
toolCall={permissionRequest.toolCall}
|
||||
onResponse={handlePermissionResponse}
|
||||
onClose={() => setPermissionRequest(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
83
packages/vscode-ide-companion/src/webview/MessageHandler.ts
Normal file
83
packages/vscode-ide-companion/src/webview/MessageHandler.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { QwenAgentManager } from '../services/qwenAgentManager.js';
|
||||
import type { ConversationStore } from '../services/conversationStore.js';
|
||||
import { MessageRouter } from './handlers/MessageRouter.js';
|
||||
|
||||
/**
|
||||
* MessageHandler (Refactored Version)
|
||||
* This is a lightweight wrapper class that internally uses MessageRouter and various sub-handlers
|
||||
* Maintains interface compatibility with the original code
|
||||
*/
|
||||
export class MessageHandler {
|
||||
private router: MessageRouter;
|
||||
|
||||
constructor(
|
||||
agentManager: QwenAgentManager,
|
||||
conversationStore: ConversationStore,
|
||||
currentConversationId: string | null,
|
||||
sendToWebView: (message: unknown) => void,
|
||||
) {
|
||||
this.router = new MessageRouter(
|
||||
agentManager,
|
||||
conversationStore,
|
||||
currentConversationId,
|
||||
sendToWebView,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route messages to the corresponding handler
|
||||
*/
|
||||
async route(message: { type: string; data?: unknown }): Promise<void> {
|
||||
await this.router.route(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current session ID
|
||||
*/
|
||||
setCurrentConversationId(id: string | null): void {
|
||||
this.router.setCurrentConversationId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID
|
||||
*/
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.router.getCurrentConversationId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set permission handler
|
||||
*/
|
||||
setPermissionHandler(
|
||||
handler: (message: { type: string; data: { optionId: string } }) => void,
|
||||
): void {
|
||||
this.router.setPermissionHandler(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set login handler
|
||||
*/
|
||||
setLoginHandler(handler: () => Promise<void>): void {
|
||||
this.router.setLoginHandler(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append stream content
|
||||
*/
|
||||
appendStreamContent(chunk: string): void {
|
||||
this.router.appendStreamContent(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if saving checkpoint
|
||||
*/
|
||||
getIsSavingCheckpoint(): boolean {
|
||||
return this.router.getIsSavingCheckpoint();
|
||||
}
|
||||
}
|
||||
385
packages/vscode-ide-companion/src/webview/PanelManager.ts
Normal file
385
packages/vscode-ide-companion/src/webview/PanelManager.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Panel and Tab Manager
|
||||
* Responsible for managing the creation, display, and tab tracking of WebView Panels
|
||||
*/
|
||||
export class PanelManager {
|
||||
private panel: vscode.WebviewPanel | null = null;
|
||||
private panelTab: vscode.Tab | null = null;
|
||||
// Best-effort tracking of the group (by view column) that currently hosts
|
||||
// the Qwen webview. We update this when creating/revealing the panel and
|
||||
// whenever we can capture the Tab from the tab model.
|
||||
private panelGroupViewColumn: vscode.ViewColumn | null = null;
|
||||
|
||||
constructor(
|
||||
private extensionUri: vscode.Uri,
|
||||
private onPanelDispose: () => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the current Panel
|
||||
*/
|
||||
getPanel(): vscode.WebviewPanel | null {
|
||||
return this.panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Panel (for restoration)
|
||||
*/
|
||||
setPanel(panel: vscode.WebviewPanel): void {
|
||||
console.log('[PanelManager] Setting panel for restoration');
|
||||
this.panel = panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new WebView Panel
|
||||
* @returns Whether it is a newly created Panel
|
||||
*/
|
||||
async createPanel(): Promise<boolean> {
|
||||
if (this.panel) {
|
||||
return false; // Panel already exists
|
||||
}
|
||||
|
||||
// First, check if there's an existing Qwen Code group
|
||||
const existingGroup = this.findExistingQwenCodeGroup();
|
||||
|
||||
if (existingGroup) {
|
||||
// If Qwen Code webview already exists in a locked group, create the new panel in that same group
|
||||
console.log(
|
||||
'[PanelManager] Found existing Qwen Code group, creating panel in same group',
|
||||
);
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: existingGroup.viewColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
// Track the group column hosting this panel
|
||||
this.panelGroupViewColumn = existingGroup.viewColumn;
|
||||
} else {
|
||||
// If no existing Qwen Code group, create a new group to the right of the active editor group
|
||||
try {
|
||||
// Create a new group to the right of the current active group
|
||||
await vscode.commands.executeCommand('workbench.action.newGroupRight');
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[PanelManager] Failed to create right editor group (continuing):',
|
||||
error,
|
||||
);
|
||||
// Fallback: create in current group
|
||||
const activeColumn =
|
||||
vscode.window.activeTextEditor?.viewColumn || vscode.ViewColumn.One;
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: activeColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
// Lock the group after creation
|
||||
await this.autoLockEditorGroup();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the new group's view column (should be the active one after creating right)
|
||||
const newGroupColumn = vscode.window.tabGroups.activeTabGroup.viewColumn;
|
||||
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: newGroupColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Lock the group after creation
|
||||
await this.autoLockEditorGroup();
|
||||
|
||||
// Track the newly created group's column
|
||||
this.panelGroupViewColumn = newGroupColumn;
|
||||
}
|
||||
|
||||
// Set panel icon to Qwen logo
|
||||
this.panel.iconPath = vscode.Uri.joinPath(
|
||||
this.extensionUri,
|
||||
'assets',
|
||||
'icon.png',
|
||||
);
|
||||
|
||||
// Try to capture Tab info shortly after creation so we can track the
|
||||
// precise group even if the user later drags the tab between groups.
|
||||
this.captureTab();
|
||||
|
||||
return true; // New panel created
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the group and view column where the existing Qwen Code webview is located
|
||||
* @returns The found group and view column, or undefined if not found
|
||||
*/
|
||||
private findExistingQwenCodeGroup():
|
||||
| { group: vscode.TabGroup; viewColumn: vscode.ViewColumn }
|
||||
| undefined {
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
const input: unknown = (tab as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
|
||||
if (
|
||||
isWebviewInput(input) &&
|
||||
input.viewType === 'mainThreadWebview-qwenCode.chat'
|
||||
) {
|
||||
// Found an existing Qwen Code tab
|
||||
console.log('[PanelManager] Found existing Qwen Code group:', {
|
||||
viewColumn: group.viewColumn,
|
||||
tabCount: group.tabs.length,
|
||||
isActive: group.isActive,
|
||||
});
|
||||
return {
|
||||
group,
|
||||
viewColumn: group.viewColumn,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-lock editor group (only called when creating a new Panel)
|
||||
* After creating/revealing the WebviewPanel, lock the active editor group so
|
||||
* the group stays dedicated (users can still unlock manually). We still
|
||||
* temporarily unlock before creation to allow adding tabs to an existing
|
||||
* group; this method restores the locked state afterwards.
|
||||
*/
|
||||
async autoLockEditorGroup(): Promise<void> {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// The newly created panel is focused (preserveFocus: false), so this
|
||||
// locks the correct, active editor group.
|
||||
await vscode.commands.executeCommand('workbench.action.lockEditorGroup');
|
||||
console.log('[PanelManager] Group locked after panel creation');
|
||||
} catch (error) {
|
||||
console.warn('[PanelManager] Failed to lock editor group:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Panel (reveal if exists, otherwise do nothing)
|
||||
* @param preserveFocus Whether to preserve focus
|
||||
*/
|
||||
revealPanel(preserveFocus: boolean = true): void {
|
||||
if (this.panel) {
|
||||
// Prefer revealing in the currently tracked group to avoid reflowing groups.
|
||||
const trackedColumn = (
|
||||
this.panelTab as unknown as {
|
||||
group?: { viewColumn?: vscode.ViewColumn };
|
||||
}
|
||||
)?.group?.viewColumn as vscode.ViewColumn | undefined;
|
||||
const targetColumn: vscode.ViewColumn =
|
||||
trackedColumn ??
|
||||
this.panelGroupViewColumn ??
|
||||
vscode.window.tabGroups.activeTabGroup.viewColumn;
|
||||
this.panel.reveal(targetColumn, preserveFocus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the Tab corresponding to the WebView Panel
|
||||
* Used for tracking and managing Tab state
|
||||
*/
|
||||
captureTab(): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer slightly so the tab model is updated after create/reveal
|
||||
setTimeout(() => {
|
||||
const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs);
|
||||
const match = allTabs.find((t) => {
|
||||
// Type guard for webview tab input
|
||||
const input: unknown = (t as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
const isWebview = isWebviewInput(input);
|
||||
const sameViewType = isWebview && input.viewType === 'qwenCode.chat';
|
||||
const sameLabel = t.label === this.panel!.title;
|
||||
return !!(sameViewType || sameLabel);
|
||||
});
|
||||
this.panelTab = match ?? null;
|
||||
// Update last-known group column if we can read it from the captured tab
|
||||
try {
|
||||
const groupViewColumn = (
|
||||
this.panelTab as unknown as {
|
||||
group?: { viewColumn?: vscode.ViewColumn };
|
||||
}
|
||||
)?.group?.viewColumn;
|
||||
if (groupViewColumn !== null) {
|
||||
this.panelGroupViewColumn = groupViewColumn as vscode.ViewColumn;
|
||||
}
|
||||
} catch {
|
||||
// Best effort only; ignore if the API shape differs
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dispose event handler for the Panel
|
||||
* @param disposables Array used to store Disposable objects
|
||||
*/
|
||||
registerDisposeHandler(disposables: vscode.Disposable[]): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel.onDidDispose(
|
||||
() => {
|
||||
// Capture the group we intend to clean up before we clear fields
|
||||
const targetColumn: vscode.ViewColumn | null =
|
||||
// Prefer the group from the captured tab if available
|
||||
((
|
||||
this.panelTab as unknown as {
|
||||
group?: { viewColumn?: vscode.ViewColumn };
|
||||
}
|
||||
)?.group?.viewColumn as vscode.ViewColumn | undefined) ??
|
||||
// Fall back to our last-known group column
|
||||
this.panelGroupViewColumn ??
|
||||
null;
|
||||
|
||||
this.panel = null;
|
||||
this.panelTab = null;
|
||||
this.onPanelDispose();
|
||||
|
||||
// After VS Code updates its tab model, check if that group is now
|
||||
// empty (and typically locked for Qwen). If so, close the group to
|
||||
// avoid leaving an empty locked column when the user closes Qwen.
|
||||
if (targetColumn !== null) {
|
||||
const column: vscode.ViewColumn = targetColumn;
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const groups = vscode.window.tabGroups.all;
|
||||
const group = groups.find((g) => g.viewColumn === column);
|
||||
// If the group that hosted Qwen is now empty, close it to avoid
|
||||
// leaving an empty locked column around. VS Code's stable API
|
||||
// does not expose the lock state on TabGroup, so we only check
|
||||
// for emptiness here.
|
||||
if (group && group.tabs.length === 0) {
|
||||
// Focus the group we want to close
|
||||
await this.focusGroupByColumn(column);
|
||||
// Try closeGroup first; fall back to removeActiveEditorGroup
|
||||
try {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.closeGroup',
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.removeActiveEditorGroup',
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[PanelManager] Failed to close empty group after Qwen panel disposed:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[PanelManager] Error while trying to close empty Qwen group:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
},
|
||||
null,
|
||||
disposables,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the editor group at the given view column by stepping left/right.
|
||||
* This avoids depending on Nth-group focus commands that may not exist.
|
||||
*/
|
||||
private async focusGroupByColumn(target: vscode.ViewColumn): Promise<void> {
|
||||
const maxHops = 20; // safety guard for unusual layouts
|
||||
let hops = 0;
|
||||
while (
|
||||
vscode.window.tabGroups.activeTabGroup.viewColumn !== target &&
|
||||
hops < maxHops
|
||||
) {
|
||||
const current = vscode.window.tabGroups.activeTabGroup.viewColumn;
|
||||
if (current < target) {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.focusRightGroup',
|
||||
);
|
||||
} else if (current > target) {
|
||||
await vscode.commands.executeCommand('workbench.action.focusLeftGroup');
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
hops++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the view state change event handler
|
||||
* @param disposables Array used to store Disposable objects
|
||||
*/
|
||||
registerViewStateChangeHandler(disposables: vscode.Disposable[]): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel.onDidChangeViewState(
|
||||
() => {
|
||||
if (this.panel && this.panel.visible) {
|
||||
this.captureTab();
|
||||
}
|
||||
},
|
||||
null,
|
||||
disposables,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose Panel
|
||||
*/
|
||||
dispose(): void {
|
||||
this.panel?.dispose();
|
||||
this.panel = null;
|
||||
this.panelTab = null;
|
||||
}
|
||||
}
|
||||
50
packages/vscode-ide-companion/src/webview/WebViewContent.ts
Normal file
50
packages/vscode-ide-companion/src/webview/WebViewContent.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { escapeHtml } from './utils/webviewUtils.js';
|
||||
|
||||
/**
|
||||
* WebView HTML Content Generator
|
||||
* Responsible for generating the HTML content of the WebView
|
||||
*/
|
||||
export class WebViewContent {
|
||||
/**
|
||||
* Generate HTML content for the WebView
|
||||
* @param panel WebView Panel
|
||||
* @param extensionUri Extension URI
|
||||
* @returns HTML string
|
||||
*/
|
||||
static generate(
|
||||
panel: vscode.WebviewPanel,
|
||||
extensionUri: vscode.Uri,
|
||||
): string {
|
||||
const scriptUri = panel.webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'),
|
||||
);
|
||||
|
||||
// Convert extension URI for webview access - this allows frontend to construct resource paths
|
||||
const extensionUriForWebview = panel.webview.asWebviewUri(extensionUri);
|
||||
|
||||
// Escape URI for HTML to prevent potential injection attacks
|
||||
const safeExtensionUri = escapeHtml(extensionUriForWebview.toString());
|
||||
const safeScriptUri = escapeHtml(scriptUri.toString());
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${panel.webview.cspSource}; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
|
||||
<title>Qwen Code</title>
|
||||
</head>
|
||||
<body data-extension-uri="${safeExtensionUri}">
|
||||
<div id="root"></div>
|
||||
<script src="${safeScriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
1225
packages/vscode-ide-companion/src/webview/WebViewProvider.ts
Normal file
1225
packages/vscode-ide-companion/src/webview/WebViewProvider.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import type { PermissionOption, ToolCall } from './PermissionRequest.js';
|
||||
|
||||
interface PermissionDrawerProps {
|
||||
isOpen: boolean;
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
isOpen,
|
||||
options,
|
||||
toolCall,
|
||||
onResponse,
|
||||
onClose,
|
||||
}) => {
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const [customMessage, setCustomMessage] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
|
||||
// Prefer file name from locations, fall back to content[].path if present
|
||||
const getAffectedFileName = (): string => {
|
||||
const fromLocations = toolCall.locations?.[0]?.path;
|
||||
if (fromLocations) {
|
||||
return fromLocations.split('/').pop() || fromLocations;
|
||||
}
|
||||
// Some tool calls (e.g. write/edit with diff content) only include path in content
|
||||
const fromContent = Array.isArray(toolCall.content)
|
||||
? (
|
||||
toolCall.content.find(
|
||||
(c: unknown) =>
|
||||
typeof c === 'object' &&
|
||||
c !== null &&
|
||||
'path' in (c as Record<string, unknown>),
|
||||
) as { path?: unknown } | undefined
|
||||
)?.path
|
||||
: undefined;
|
||||
if (typeof fromContent === 'string' && fromContent.length > 0) {
|
||||
return fromContent.split('/').pop() || fromContent;
|
||||
}
|
||||
return 'file';
|
||||
};
|
||||
|
||||
// Get the title for the permission request
|
||||
const getTitle = () => {
|
||||
if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
|
||||
const fileName = getAffectedFileName();
|
||||
return (
|
||||
<>
|
||||
Make this edit to{' '}
|
||||
<span className="font-mono text-[var(--app-primary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (toolCall.kind === 'execute' || toolCall.kind === 'bash') {
|
||||
return 'Allow this bash command?';
|
||||
}
|
||||
if (toolCall.kind === 'read') {
|
||||
const fileName = getAffectedFileName();
|
||||
return (
|
||||
<>
|
||||
Allow read from{' '}
|
||||
<span className="font-mono text-[var(--app-primary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
);
|
||||
}
|
||||
return toolCall.title || 'Permission Required';
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys 1-9 for quick select
|
||||
const numMatch = e.key.match(/^[1-9]$/);
|
||||
if (
|
||||
numMatch &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
const index = parseInt(e.key, 10) - 1;
|
||||
if (index < options.length) {
|
||||
e.preventDefault();
|
||||
onResponse(options[index].optionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys for navigation
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const totalItems = options.length + 1; // +1 for custom input
|
||||
if (e.key === 'ArrowDown') {
|
||||
setFocusedIndex((prev) => (prev + 1) % totalItems);
|
||||
} else {
|
||||
setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems);
|
||||
}
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (focusedIndex < options.length) {
|
||||
onResponse(options[focusedIndex].optionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to cancel permission and close (align with CLI behavior)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
const rejectOptionId =
|
||||
options.find((o) => o.kind.includes('reject'))?.optionId ||
|
||||
options.find((o) => o.optionId === 'cancel')?.optionId ||
|
||||
'cancel';
|
||||
onResponse(rejectOptionId);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, options, onResponse, onClose, focusedIndex]);
|
||||
|
||||
// Focus container when opened
|
||||
useEffect(() => {
|
||||
if (isOpen && containerRef.current) {
|
||||
containerRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset focus to the first option when the drawer opens or the options change
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFocusedIndex(0);
|
||||
}
|
||||
}, [isOpen, options.length]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
|
||||
{/* Main container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex flex-col rounded-large border p-2 outline-none animate-slide-up"
|
||||
style={{
|
||||
backgroundColor: 'var(--app-input-secondary-background)',
|
||||
borderColor: 'var(--app-input-border)',
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-focused-index={focusedIndex}
|
||||
>
|
||||
{/* Background layer */}
|
||||
<div
|
||||
className="p-2 absolute inset-0 rounded-large"
|
||||
style={{ backgroundColor: 'var(--app-input-background)' }}
|
||||
/>
|
||||
|
||||
{/* Title + Description (from toolCall.title) */}
|
||||
<div className="relative z-[1] text-[1.1em] text-[var(--app-primary-foreground)] flex flex-col min-h-0">
|
||||
<div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
|
||||
{getTitle()}
|
||||
</div>
|
||||
{(toolCall.kind === 'edit' ||
|
||||
toolCall.kind === 'write' ||
|
||||
toolCall.kind === 'read' ||
|
||||
toolCall.kind === 'execute' ||
|
||||
toolCall.kind === 'bash') &&
|
||||
toolCall.title && (
|
||||
<div
|
||||
/* 13px, normal font weight; normal whitespace wrapping + long word breaking; maximum 3 lines with overflow ellipsis */
|
||||
className="text-[13px] font-normal text-[var(--app-secondary-foreground)] opacity-90 font-mono whitespace-normal break-words q-line-clamp-3 mb-2"
|
||||
style={{
|
||||
fontSize: '.9em',
|
||||
color: 'var(--app-secondary-foreground)',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
title={toolCall.title}
|
||||
>
|
||||
{toolCall.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative z-[1] flex flex-col gap-1 pb-1">
|
||||
{options.map((option, index) => {
|
||||
const isFocused = focusedIndex === index;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.optionId}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-button-background)] ${
|
||||
isFocused
|
||||
? 'text-[var(--app-list-active-foreground)] bg-[var(--app-list-active-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
}`}
|
||||
onClick={() => onResponse(option.optionId)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
>
|
||||
{/* Number badge */}
|
||||
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold opacity-60">
|
||||
{index + 1}
|
||||
</span>
|
||||
{/* Option text */}
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom message input (extracted component) */}
|
||||
{(() => {
|
||||
const isFocused = focusedIndex === options.length;
|
||||
const rejectOptionId = options.find((o) =>
|
||||
o.kind.includes('reject'),
|
||||
)?.optionId;
|
||||
return (
|
||||
<CustomMessageInputRow
|
||||
isFocused={isFocused}
|
||||
customMessage={customMessage}
|
||||
setCustomMessage={setCustomMessage}
|
||||
onFocusRow={() => setFocusedIndex(options.length)}
|
||||
onSubmitReject={() => {
|
||||
if (rejectOptionId) {
|
||||
onResponse(rejectOptionId);
|
||||
}
|
||||
}}
|
||||
inputRef={customInputRef}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CustomMessageInputRow: Reusable custom input row component (without hooks)
|
||||
*/
|
||||
interface CustomMessageInputRowProps {
|
||||
isFocused: boolean;
|
||||
customMessage: string;
|
||||
setCustomMessage: (val: string) => void;
|
||||
onFocusRow: () => void; // Set focus when mouse enters or input box is focused
|
||||
onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option)
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
|
||||
isFocused,
|
||||
customMessage,
|
||||
setCustomMessage,
|
||||
onFocusRow,
|
||||
onSubmitReject,
|
||||
inputRef,
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
|
||||
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
|
||||
}`}
|
||||
onMouseEnter={onFocusRow}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef as React.LegacyRef<HTMLInputElement> | undefined}
|
||||
type="text"
|
||||
placeholder="Tell Qwen what to do instead"
|
||||
spellCheck={false}
|
||||
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
|
||||
style={{ color: 'var(--app-input-foreground)' }}
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
onFocus={onFocusRow}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmitReject();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface PermissionOption {
|
||||
name: string;
|
||||
kind: string;
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
title?: string;
|
||||
kind?: string;
|
||||
toolCallId?: string;
|
||||
rawInput?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
content?: Array<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequestProps {
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Edit mode related icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Edit pencil icon (16x16)
|
||||
* Used for "Ask before edits" mode
|
||||
*/
|
||||
export const EditPencilIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Auto/fast-forward icon (16x16)
|
||||
* Used for "Edit automatically" mode
|
||||
*/
|
||||
export const AutoEditIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M2.53 3.956A1 1 0 0 0 1 4.804v6.392a1 1 0 0 0 1.53.848l5.113-3.196c.16-.1.279-.233.357-.383v2.73a1 1 0 0 0 1.53.849l5.113-3.196a1 1 0 0 0 0-1.696L9.53 3.956A1 1 0 0 0 8 4.804v2.731a.992.992 0 0 0-.357-.383L2.53 3.956Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Plan mode/bars icon (16x16)
|
||||
* Used for "Plan mode"
|
||||
*/
|
||||
export const PlanModeIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1ZM10.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Code brackets icon (20x20)
|
||||
* Used for active file indicator
|
||||
*/
|
||||
export const CodeBracketsIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06ZM11.377 2.011a.75.75 0 0 1 .612.867l-2.5 14.5a.75.75 0 0 1-1.478-.255l2.5-14.5a.75.75 0 0 1 .866-.612Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Hide context (eye slash) icon (20x20)
|
||||
* Used to indicate the active selection will NOT be auto-loaded into context
|
||||
*/
|
||||
export const HideContextIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l14.5 14.5a.75.75 0 1 0 1.06-1.06l-1.745-1.745a10.029 10.029 0 0 0 3.3-4.38 1.651 1.651 0 0 0 0-1.185A10.004 10.004 0 0 0 9.999 3a9.956 9.956 0 0 0-4.744 1.194L3.28 2.22ZM7.752 6.69l1.092 1.092a2.5 2.5 0 0 1 3.374 3.373l1.091 1.092a4 4 0 0 0-5.557-5.557Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="m10.748 13.93 2.523 2.523a9.987 9.987 0 0 1-3.27.547c-4.258 0-7.894-2.66-9.337-6.41a1.651 1.651 0 0 1 0-1.186A10.007 10.007 0 0 1 2.839 6.02L6.07 9.252a4 4 0 0 0 4.678 4.678Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Slash command icon (20x20)
|
||||
* Used for command menu button
|
||||
*/
|
||||
export const SlashCommandIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Link/attachment icon (20x20)
|
||||
* Used for attach context button
|
||||
*/
|
||||
export const LinkIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Open diff icon (16x16)
|
||||
* Used for opening diff in VS Code
|
||||
*/
|
||||
export const OpenDiffIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M13.5 7l-4-4v3h-6v2h6v3l4-4z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* File and document related icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* File document icon (16x16)
|
||||
* Used for file completion menu
|
||||
*/
|
||||
export const FileIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M9 2H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7l-5-5zm3 7V3.5L10.5 2H10v3a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V2H4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1zM6 3h3v2H6V3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FileListIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M5 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Save document icon (16x16)
|
||||
* Used for save session button
|
||||
*/
|
||||
export const SaveDocumentIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M2.66663 2.66663H10.6666L13.3333 5.33329V13.3333H2.66663V2.66663Z" />
|
||||
<path d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Folder icon (16x16)
|
||||
* Useful for directory entries in completion lists
|
||||
*/
|
||||
export const FolderIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M1.5 3A1.5 1.5 0 0 1 3 1.5h3.086a1.5 1.5 0 0 1 1.06.44L8.5 3H13A1.5 1.5 0 0 1 14.5 4.5v7A1.5 1.5 0 0 1 13 13H3A1.5 1.5 0 0 1 1.5 11.5v-8Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Navigation and action icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Chevron down icon (20x20)
|
||||
* Used for dropdown arrows
|
||||
*/
|
||||
export const ChevronDownIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Plus icon (20x20)
|
||||
* Used for new session button
|
||||
*/
|
||||
export const PlusIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Small plus icon (16x16)
|
||||
* Used for default attachment type
|
||||
*/
|
||||
export const PlusSmallIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 2a.5.5 0 0 1 .5.5V5h2.5a.5.5 0 0 1 0 1H8.5v2.5a.5.5 0 0 1-1 0V6H5a.5.5 0 0 1 0-1h2.5V2.5A.5.5 0 0 1 8 2Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Arrow up icon (20x20)
|
||||
* Used for send message button
|
||||
*/
|
||||
export const ArrowUpIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Close X icon (14x14)
|
||||
* Used for close buttons in banners and dialogs
|
||||
*/
|
||||
export const CloseIcon: React.FC<IconProps> = ({
|
||||
size = 14,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M1 1L13 13M1 13L13 1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CloseSmallIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Search/magnifying glass icon (20x20)
|
||||
* Used for search input
|
||||
*/
|
||||
export const SearchIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh/reload icon (16x16)
|
||||
* Used for refresh session list
|
||||
*/
|
||||
export const RefreshIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M13.3333 8C13.3333 10.9455 10.9455 13.3333 8 13.3333C5.05451 13.3333 2.66663 10.9455 2.66663 8C2.66663 5.05451 5.05451 2.66663 8 2.66663" />
|
||||
<path d="M10.6666 8L13.3333 8M13.3333 8L13.3333 5.33333M13.3333 8L10.6666 10.6667" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Special UI icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
interface ThinkingIconProps extends IconProps {
|
||||
/**
|
||||
* Whether thinking is enabled (affects styling)
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const ThinkingIcon: React.FC<ThinkingIconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
enabled = false,
|
||||
style,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8.00293 1.11523L8.35059 1.12402H8.35352C11.9915 1.30834 14.8848 4.31624 14.8848 8C14.8848 11.8025 11.8025 14.8848 8 14.8848C4.19752 14.8848 1.11523 11.8025 1.11523 8C1.11523 7.67691 1.37711 7.41504 1.7002 7.41504C2.02319 7.41514 2.28516 7.67698 2.28516 8C2.28516 11.1563 4.84369 13.7148 8 13.7148C11.1563 13.7148 13.7148 11.1563 13.7148 8C13.7148 4.94263 11.3141 2.4464 8.29492 2.29297V2.29199L7.99609 2.28516H7.9873V2.28418L7.89648 2.27539L7.88281 2.27441V2.27344C7.61596 2.21897 7.41513 1.98293 7.41504 1.7002C7.41504 1.37711 7.67691 1.11523 8 1.11523H8.00293ZM8 3.81543C8.32309 3.81543 8.58496 4.0773 8.58496 4.40039V7.6377L10.9619 8.82715C11.2505 8.97169 11.3678 9.32256 11.2236 9.61133C11.0972 9.86425 10.8117 9.98544 10.5488 9.91504L10.5352 9.91211V9.91016L10.4502 9.87891L10.4385 9.87402V9.87305L7.73828 8.52344C7.54007 8.42433 7.41504 8.22155 7.41504 8V4.40039C7.41504 4.0773 7.67691 3.81543 8 3.81543ZM2.44336 5.12695C2.77573 5.19517 3.02597 5.48929 3.02637 5.8418C3.02637 6.19456 2.7761 6.49022 2.44336 6.55859L2.2959 6.57324C1.89241 6.57324 1.56543 6.24529 1.56543 5.8418C1.56588 5.43853 1.89284 5.1123 2.2959 5.1123L2.44336 5.12695ZM3.46094 2.72949C3.86418 2.72984 4.19017 3.05712 4.19043 3.45996V3.46094C4.19009 3.86393 3.86392 4.19008 3.46094 4.19043H3.45996C3.05712 4.19017 2.72983 3.86419 2.72949 3.46094V3.45996C2.72976 3.05686 3.05686 2.72976 3.45996 2.72949H3.46094ZM5.98926 1.58008C6.32235 1.64818 6.57324 1.94276 6.57324 2.2959L6.55859 2.44336C6.49022 2.7761 6.19456 3.02637 5.8418 3.02637C5.43884 3.02591 5.11251 2.69895 5.1123 2.2959L5.12695 2.14844C5.19504 1.81591 5.48906 1.56583 5.8418 1.56543L5.98926 1.58008Z"
|
||||
strokeWidth="0.27"
|
||||
style={{
|
||||
stroke: enabled
|
||||
? 'var(--app-qwen-ivory)'
|
||||
: 'var(--app-secondary-foreground)',
|
||||
fill: enabled
|
||||
? 'var(--app-qwen-ivory)'
|
||||
: 'var(--app-secondary-foreground)',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TerminalIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.14648 7.14648C5.34175 6.95122 5.65825 6.95122 5.85352 7.14648L8.35352 9.64648C8.44728 9.74025 8.5 9.86739 8.5 10C8.5 10.0994 8.47037 10.1958 8.41602 10.2773L8.35352 10.3535L5.85352 12.8535C5.65825 13.0488 5.34175 13.0488 5.14648 12.8535C4.95122 12.6583 4.95122 12.3417 5.14648 12.1465L7.29297 10L5.14648 7.85352C4.95122 7.65825 4.95122 7.34175 5.14648 7.14648Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M14.5 12C14.7761 12 15 12.2239 15 12.5C15 12.7761 14.7761 13 14.5 13H9.5C9.22386 13 9 12.7761 9 12.5C9 12.2239 9.22386 12 9.5 12H14.5Z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.5 4C17.3284 4 18 4.67157 18 5.5V14.5C18 15.3284 17.3284 16 16.5 16H3.5C2.67157 16 2 15.3284 2 14.5V5.5C2 4.67157 2.67157 4 3.5 4H16.5ZM3.5 5C3.22386 5 3 5.22386 3 5.5V14.5C3 14.7761 3.22386 15 3.5 15H16.5C16.7761 15 17 14.7761 17 14.5V5.5C17 5.22386 16.7761 5 16.5 5H3.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Status and state related icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Plan completed icon (14x14)
|
||||
* Used for completed plan items
|
||||
*/
|
||||
export const PlanCompletedIcon: React.FC<IconProps> = ({
|
||||
size = 14,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="7" cy="7" r="6" fill="currentColor" opacity="0.2" />
|
||||
<path
|
||||
d="M4 7.5L6 9.5L10 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Plan in progress icon (14x14)
|
||||
* Used for in-progress plan items
|
||||
*/
|
||||
export const PlanInProgressIcon: React.FC<IconProps> = ({
|
||||
size = 14,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="7"
|
||||
cy="7"
|
||||
r="5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Plan pending icon (14x14)
|
||||
* Used for pending plan items
|
||||
*/
|
||||
export const PlanPendingIcon: React.FC<IconProps> = ({
|
||||
size = 14,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="7"
|
||||
cy="7"
|
||||
r="5.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Warning triangle icon (20x20)
|
||||
* Used for warning messages
|
||||
*/
|
||||
export const WarningTriangleIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* User profile icon (16x16)
|
||||
* Used for login command
|
||||
*/
|
||||
export const UserIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SymbolIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 1a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 7.293V1.5A.5.5 0 0 1 8 1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SelectionIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Stop icon for canceling operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Stop/square icon (16x16)
|
||||
* Used for stop/cancel operations
|
||||
*/
|
||||
export const StopIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type { IconProps } from './types.js';
|
||||
export { FileIcon, FileListIcon, FolderIcon } from './FileIcons.js';
|
||||
|
||||
// Navigation icons
|
||||
export {
|
||||
ChevronDownIcon,
|
||||
PlusIcon,
|
||||
PlusSmallIcon,
|
||||
ArrowUpIcon,
|
||||
CloseIcon,
|
||||
CloseSmallIcon,
|
||||
SearchIcon,
|
||||
RefreshIcon,
|
||||
} from './NavigationIcons.js';
|
||||
|
||||
// Edit mode icons
|
||||
export {
|
||||
EditPencilIcon,
|
||||
AutoEditIcon,
|
||||
PlanModeIcon,
|
||||
CodeBracketsIcon,
|
||||
HideContextIcon,
|
||||
SlashCommandIcon,
|
||||
LinkIcon,
|
||||
OpenDiffIcon,
|
||||
} from './EditIcons.js';
|
||||
|
||||
// Status icons
|
||||
export {
|
||||
PlanCompletedIcon,
|
||||
PlanInProgressIcon,
|
||||
PlanPendingIcon,
|
||||
WarningTriangleIcon,
|
||||
UserIcon,
|
||||
SymbolIcon,
|
||||
SelectionIcon,
|
||||
} from './StatusIcons.js';
|
||||
|
||||
// Special icons
|
||||
export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js';
|
||||
|
||||
// Stop icon
|
||||
export { StopIcon } from './StopIcon.js';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Common icon props interface
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/**
|
||||
* Icon size (width and height)
|
||||
* @default 16
|
||||
*/
|
||||
size?: number;
|
||||
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { ChevronDownIcon, PlusIcon } from '../icons/index.js';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
currentSessionTitle: string;
|
||||
onLoadSessions: () => void;
|
||||
onNewSession: () => void;
|
||||
}
|
||||
|
||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
currentSessionTitle,
|
||||
onLoadSessions,
|
||||
onNewSession,
|
||||
}) => (
|
||||
<div
|
||||
className="chat-header flex items-center select-none w-full border-b border-[var(--app-primary-border-color)] bg-[var(--app-header-background)] py-1.5 px-2.5"
|
||||
style={{ borderBottom: '1px solid var(--app-primary-border-color)' }}
|
||||
>
|
||||
<button
|
||||
className="flex items-center gap-1.5 py-0.5 px-2 bg-transparent border-none rounded cursor-pointer outline-none min-w-0 max-w-[300px] overflow-hidden text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
||||
onClick={onLoadSessions}
|
||||
title="Past conversations"
|
||||
>
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis min-w-0 font-medium">
|
||||
{currentSessionTitle}
|
||||
</span>
|
||||
<ChevronDownIcon className="w-4 h-4 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-1"></div>
|
||||
|
||||
<button
|
||||
className="flex items-center justify-center p-1 bg-transparent border-none rounded cursor-pointer outline-none hover:bg-[var(--app-ghost-button-hover-background)]"
|
||||
onClick={onNewSession}
|
||||
title="New Session"
|
||||
style={{ padding: '4px' }}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { CompletionItem } from '../../../types/completionItemTypes.js';
|
||||
|
||||
interface CompletionMenuProps {
|
||||
items: CompletionItem[];
|
||||
onSelect: (item: CompletionItem) => void;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
selectedIndex?: number;
|
||||
}
|
||||
|
||||
export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
items,
|
||||
onSelect,
|
||||
onClose,
|
||||
title,
|
||||
selectedIndex = 0,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [selected, setSelected] = useState(selectedIndex);
|
||||
// Mount state to drive a simple Tailwind transition (replaces CSS keyframes)
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setSelected(selectedIndex), [selectedIndex]);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setSelected((prev) => Math.min(prev + 1, items.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setSelected((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (items[selected]) {
|
||||
onSelect(items[selected]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [items, selected, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedEl = containerRef.current?.querySelector(
|
||||
`[data-index="${selected}"]`,
|
||||
);
|
||||
if (selectedEl) {
|
||||
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="menu"
|
||||
className={[
|
||||
// Semantic class name for readability (no CSS attached)
|
||||
'completion-menu',
|
||||
// Positioning and container styling (Tailwind)
|
||||
'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden',
|
||||
'rounded-large border bg-[var(--app-menu-background)]',
|
||||
'border-[var(--app-input-border)] max-h-[50vh] z-[1000]',
|
||||
// Mount animation (fade + slight slide up) via keyframes
|
||||
mounted ? 'animate-completion-menu-enter' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Optional top spacer for visual separation from the input */}
|
||||
<div className="h-1" />
|
||||
<div
|
||||
className={[
|
||||
// Semantic
|
||||
'completion-menu-list',
|
||||
// Scroll area
|
||||
'flex max-h-[300px] flex-col overflow-y-auto',
|
||||
// Spacing driven by theme vars
|
||||
'p-[var(--app-list-padding)] pb-2 gap-[var(--app-list-gap)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{title && (
|
||||
<div className="completion-menu-section-label px-3 py-1 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em]">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, index) => {
|
||||
const isActive = index === selected;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
data-index={index}
|
||||
role="menuitem"
|
||||
onClick={() => onSelect(item)}
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
className={[
|
||||
// Semantic
|
||||
'completion-menu-item',
|
||||
// Hit area
|
||||
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
|
||||
'p-[var(--app-list-item-padding)]',
|
||||
// Active background
|
||||
isActive ? 'bg-[var(--app-list-active-background)]' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="completion-menu-item-row flex items-center justify-between gap-2">
|
||||
{item.icon && (
|
||||
<span className="completion-menu-item-icon inline-flex h-4 w-4 items-center justify-center text-[var(--vscode-symbolIcon-fileForeground,#cccccc)]">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={[
|
||||
'completion-menu-item-label flex-1 truncate',
|
||||
isActive
|
||||
? 'text-[var(--app-list-active-foreground)]'
|
||||
: 'text-[var(--app-primary-foreground)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description && (
|
||||
<span
|
||||
className="completion-menu-item-desc max-w-[50%] truncate text-[0.9em] text-[var(--app-secondary-foreground)] opacity-70"
|
||||
title={item.description}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { generateIconUrl } from '../../utils/resourceUrl.js';
|
||||
|
||||
export const EmptyState: React.FC = () => {
|
||||
// Generate icon URL using the utility function
|
||||
const iconUri = generateIconUrl('icon.png');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
|
||||
<div className="flex flex-col items-center gap-8 w-full">
|
||||
{/* Qwen Logo */}
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<img
|
||||
src={iconUri}
|
||||
alt="Qwen Logo"
|
||||
className="w-[60px] h-[60px] object-contain"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="text-[15px] text-app-primary-foreground leading-normal font-normal max-w-[400px]">
|
||||
What to do first? Ask about this codebase or we can start writing
|
||||
code.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* FileLink component - Clickable file path links
|
||||
* Supports clicking to open files and jump to specified line and column numbers
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useVSCode } from '../../hooks/useVSCode.js';
|
||||
// Tailwind rewrite: styles from FileLink.css are now expressed as utility classes
|
||||
|
||||
/**
|
||||
* Props for FileLink
|
||||
*/
|
||||
interface FileLinkProps {
|
||||
/** File path */
|
||||
path: string;
|
||||
/** Optional line number (starting from 1) */
|
||||
line?: number | null;
|
||||
/** Optional column number (starting from 1) */
|
||||
column?: number | null;
|
||||
/** Whether to show full path, default false (show filename only) */
|
||||
showFullPath?: boolean;
|
||||
/** Optional custom class name */
|
||||
className?: string;
|
||||
/** Whether to disable click behavior (use when parent element handles clicks) */
|
||||
disableClick?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from full path
|
||||
* @param path File path
|
||||
* @returns Filename
|
||||
*/
|
||||
function getFileName(path: string): string {
|
||||
const segments = path.split(/[/\\]/);
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileLink component - Clickable file link
|
||||
*
|
||||
* Features:
|
||||
* - Click to open file
|
||||
* - Support line and column number navigation
|
||||
* - Hover to show full path
|
||||
* - Optional display mode (full path vs filename only)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FileLink path="/src/App.tsx" line={42} />
|
||||
* <FileLink path="/src/components/Button.tsx" line={10} column={5} showFullPath={true} />
|
||||
* ```
|
||||
*/
|
||||
export const FileLink: React.FC<FileLinkProps> = ({
|
||||
path,
|
||||
line,
|
||||
column,
|
||||
showFullPath = false,
|
||||
className = '',
|
||||
disableClick = false,
|
||||
}) => {
|
||||
const vscode = useVSCode();
|
||||
|
||||
/**
|
||||
* Handle click event - Send message to VSCode to open file
|
||||
*/
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Always prevent default behavior (prevent <a> tag # navigation)
|
||||
e.preventDefault();
|
||||
|
||||
if (disableClick) {
|
||||
// If click is disabled, return directly without stopping propagation
|
||||
// This allows parent elements to handle click events
|
||||
return;
|
||||
}
|
||||
|
||||
// If click is enabled, stop event propagation
|
||||
e.stopPropagation();
|
||||
|
||||
// Build full path including line and column numbers
|
||||
let fullPath = path;
|
||||
if (line !== null && line !== undefined) {
|
||||
fullPath += `:${line}`;
|
||||
if (column !== null && column !== undefined) {
|
||||
fullPath += `:${column}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[FileLink] Opening file:', fullPath);
|
||||
|
||||
vscode.postMessage({
|
||||
type: 'openFile',
|
||||
data: { path: fullPath },
|
||||
});
|
||||
};
|
||||
|
||||
// Build display text
|
||||
const displayPath = showFullPath ? path : getFileName(path);
|
||||
|
||||
// Build hover tooltip (always show full path)
|
||||
const fullDisplayText =
|
||||
line !== null && line !== undefined
|
||||
? column !== null && column !== undefined
|
||||
? `${path}:${line}:${column}`
|
||||
: `${path}:${line}`
|
||||
: path;
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
className={[
|
||||
'file-link',
|
||||
// Layout + interaction
|
||||
// Use items-center + leading-none to vertically center within surrounding rows
|
||||
'inline-flex items-center leading-none',
|
||||
disableClick
|
||||
? 'pointer-events-none cursor-[inherit] hover:no-underline'
|
||||
: 'cursor-pointer',
|
||||
// Typography + color: match theme body text and fixed size
|
||||
'text-[11px] no-underline hover:underline',
|
||||
'text-[var(--app-primary-foreground)]',
|
||||
// Transitions
|
||||
'transition-colors duration-100 ease-in-out',
|
||||
// Focus ring (keyboard nav)
|
||||
'focus:outline focus:outline-1 focus:outline-[var(--vscode-focusBorder)] focus:outline-offset-2 focus:rounded-[2px]',
|
||||
// Active state
|
||||
'active:opacity-80',
|
||||
className,
|
||||
].join(' ')}
|
||||
onClick={handleClick}
|
||||
title={fullDisplayText}
|
||||
role="button"
|
||||
aria-label={`Open file: ${fullDisplayText}`}
|
||||
// Inherit font family from context so it matches theme body text.
|
||||
>
|
||||
<span className="file-link-path">{displayPath}</span>
|
||||
{line !== null && line !== undefined && (
|
||||
<span className="file-link-location opacity-70 text-[0.9em] font-normal dark:opacity-60">
|
||||
:{line}
|
||||
{column !== null && column !== undefined && <>:{column}</>}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import {
|
||||
EditPencilIcon,
|
||||
AutoEditIcon,
|
||||
PlanModeIcon,
|
||||
CodeBracketsIcon,
|
||||
HideContextIcon,
|
||||
ThinkingIcon,
|
||||
SlashCommandIcon,
|
||||
LinkIcon,
|
||||
ArrowUpIcon,
|
||||
StopIcon,
|
||||
} from '../icons/index.js';
|
||||
import { CompletionMenu } from '../layout/CompletionMenu.js';
|
||||
import type { CompletionItem } from '../../../types/completionItemTypes.js';
|
||||
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../../../types/acpTypes.js';
|
||||
|
||||
interface InputFormProps {
|
||||
inputText: string;
|
||||
// Note: RefObject<T> carries nullability in its `current` property, so the
|
||||
// generic should be `HTMLDivElement` (not `HTMLDivElement | null`).
|
||||
inputFieldRef: React.RefObject<HTMLDivElement>;
|
||||
isStreaming: boolean;
|
||||
isWaitingForResponse: boolean;
|
||||
isComposing: boolean;
|
||||
editMode: ApprovalModeValue;
|
||||
thinkingEnabled: boolean;
|
||||
activeFileName: string | null;
|
||||
activeSelection: { startLine: number; endLine: number } | null;
|
||||
// Whether to auto-load the active editor selection/path into context
|
||||
skipAutoActiveContext: boolean;
|
||||
onInputChange: (text: string) => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onCancel: () => void;
|
||||
onToggleEditMode: () => void;
|
||||
onToggleThinking: () => void;
|
||||
onFocusActiveEditor: () => void;
|
||||
onToggleSkipAutoActiveContext: () => void;
|
||||
onShowCommandMenu: () => void;
|
||||
onAttachContext: () => void;
|
||||
completionIsOpen: boolean;
|
||||
completionItems?: CompletionItem[];
|
||||
onCompletionSelect?: (item: CompletionItem) => void;
|
||||
onCompletionClose?: () => void;
|
||||
}
|
||||
|
||||
// Get edit mode display info using helper function
|
||||
const getEditModeInfo = (editMode: ApprovalModeValue) => {
|
||||
const info = getApprovalModeInfoFromString(editMode);
|
||||
|
||||
// Map icon types to actual icons
|
||||
let icon = null;
|
||||
switch (info.iconType) {
|
||||
case 'edit':
|
||||
icon = <EditPencilIcon />;
|
||||
break;
|
||||
case 'auto':
|
||||
icon = <AutoEditIcon />;
|
||||
break;
|
||||
case 'plan':
|
||||
icon = <PlanModeIcon />;
|
||||
break;
|
||||
case 'yolo':
|
||||
icon = <AutoEditIcon />;
|
||||
break;
|
||||
default:
|
||||
icon = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
text: info.label,
|
||||
title: info.title,
|
||||
icon,
|
||||
};
|
||||
};
|
||||
|
||||
export const InputForm: React.FC<InputFormProps> = ({
|
||||
inputText,
|
||||
inputFieldRef,
|
||||
isStreaming,
|
||||
isWaitingForResponse,
|
||||
isComposing,
|
||||
editMode,
|
||||
thinkingEnabled,
|
||||
activeFileName,
|
||||
activeSelection,
|
||||
skipAutoActiveContext,
|
||||
onInputChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onToggleEditMode,
|
||||
onToggleThinking,
|
||||
onToggleSkipAutoActiveContext,
|
||||
onShowCommandMenu,
|
||||
onAttachContext,
|
||||
completionIsOpen,
|
||||
completionItems,
|
||||
onCompletionSelect,
|
||||
onCompletionClose,
|
||||
}) => {
|
||||
const editModeInfo = getEditModeInfo(editMode);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// ESC should cancel the current interaction (stop generation)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
// If composing (Chinese IME input), don't process Enter key
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
|
||||
// If CompletionMenu is open, let it handle Enter key
|
||||
if (completionIsOpen) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onSubmit(e);
|
||||
}
|
||||
onKeyDown(e);
|
||||
};
|
||||
|
||||
// Selection label like "6 lines selected"; no line numbers
|
||||
const selectedLinesCount = activeSelection
|
||||
? Math.max(1, activeSelection.endLine - activeSelection.startLine + 1)
|
||||
: 0;
|
||||
const selectedLinesText =
|
||||
selectedLinesCount > 0
|
||||
? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-1 px-4 pb-4"
|
||||
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
||||
>
|
||||
<div className="block">
|
||||
<form className="composer-form" onSubmit={onSubmit}>
|
||||
{/* Inner background layer */}
|
||||
<div className="composer-overlay" />
|
||||
|
||||
{/* Banner area */}
|
||||
<div className="input-banner" />
|
||||
|
||||
<div className="relative flex z-[1]">
|
||||
{completionIsOpen &&
|
||||
completionItems &&
|
||||
completionItems.length > 0 &&
|
||||
onCompletionSelect &&
|
||||
onCompletionClose && (
|
||||
<CompletionMenu
|
||||
items={completionItems}
|
||||
onSelect={onCompletionSelect}
|
||||
onClose={onCompletionClose}
|
||||
title={undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={inputFieldRef}
|
||||
contentEditable="plaintext-only"
|
||||
className="composer-input"
|
||||
role="textbox"
|
||||
aria-label="Message input"
|
||||
aria-multiline="true"
|
||||
data-placeholder="Ask Qwen Code …"
|
||||
// Use a data flag so CSS can show placeholder even if the browser
|
||||
// inserts an invisible <br> into contentEditable (so :empty no longer matches)
|
||||
data-empty={inputText.trim().length === 0 ? 'true' : 'false'}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
onInputChange(target.textContent || '');
|
||||
}}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="composer-actions">
|
||||
{/* Edit mode button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-compact btn-text-compact--primary"
|
||||
title={editModeInfo.title}
|
||||
onClick={onToggleEditMode}
|
||||
>
|
||||
{editModeInfo.icon}
|
||||
{/* Let the label truncate with ellipsis; hide on very small screens */}
|
||||
<span className="hidden sm:inline">{editModeInfo.text}</span>
|
||||
</button>
|
||||
|
||||
{/* Active file indicator */}
|
||||
{activeFileName && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-compact btn-text-compact--primary"
|
||||
title={(() => {
|
||||
if (skipAutoActiveContext) {
|
||||
return selectedLinesText
|
||||
? `Active selection will NOT be auto-loaded into context: ${selectedLinesText}`
|
||||
: `Active file will NOT be auto-loaded into context: ${activeFileName}`;
|
||||
}
|
||||
return selectedLinesText
|
||||
? `Showing Qwen Code your current selection: ${selectedLinesText}`
|
||||
: `Showing Qwen Code your current file: ${activeFileName}`;
|
||||
})()}
|
||||
onClick={onToggleSkipAutoActiveContext}
|
||||
>
|
||||
{skipAutoActiveContext ? (
|
||||
<HideContextIcon />
|
||||
) : (
|
||||
<CodeBracketsIcon />
|
||||
)}
|
||||
{/* Truncate file path/selection; hide label on very small screens */}
|
||||
<span className="hidden sm:inline">
|
||||
{selectedLinesText || activeFileName}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{/* Thinking button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-icon-compact ${thinkingEnabled ? 'btn-icon-compact--active' : ''}`}
|
||||
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
|
||||
onClick={onToggleThinking}
|
||||
>
|
||||
<ThinkingIcon enabled={thinkingEnabled} />
|
||||
</button>
|
||||
|
||||
{/* Command button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon-compact hover:text-[var(--app-primary-foreground)]"
|
||||
title="Show command menu (/)"
|
||||
onClick={onShowCommandMenu}
|
||||
>
|
||||
<SlashCommandIcon />
|
||||
</button>
|
||||
|
||||
{/* Attach button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon-compact hover:text-[var(--app-primary-foreground)]"
|
||||
title="Attach context (Cmd/Ctrl + /)"
|
||||
onClick={onAttachContext}
|
||||
>
|
||||
<LinkIcon />
|
||||
</button>
|
||||
|
||||
{/* Send/Stop button */}
|
||||
{isStreaming || isWaitingForResponse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
|
||||
onClick={onCancel}
|
||||
title="Stop generation"
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
|
||||
disabled={!inputText.trim()}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { groupSessionsByDate } from '../../utils/sessionGrouping.js';
|
||||
import { getTimeAgo } from '../../utils/timeUtils.js';
|
||||
import { SearchIcon } from '../icons/index.js';
|
||||
|
||||
interface SessionSelectorProps {
|
||||
visible: boolean;
|
||||
sessions: Array<Record<string, unknown>>;
|
||||
currentSessionId: string | null;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
hasMore?: boolean;
|
||||
isLoading?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session selector component
|
||||
* Display session list and support search and selection
|
||||
*/
|
||||
export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
||||
visible,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelectSession,
|
||||
onClose,
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
onLoadMore,
|
||||
}) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasNoSessions = sessions.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="session-selector-backdrop fixed top-0 left-0 right-0 bottom-0 z-[999] bg-transparent"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="session-dropdown fixed bg-[var(--app-menu-background)] rounded-[var(--corner-radius-small)] w-[min(400px,calc(100vw-32px))] max-h-[min(500px,50vh)] flex flex-col shadow-[0_4px_16px_rgba(0,0,0,0.1)] z-[1000] outline-none text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)]"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
top: '30px',
|
||||
left: '10px',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search Box */}
|
||||
<div className="session-search p-2 flex items-center gap-2">
|
||||
<SearchIcon className="session-search-icon w-4 h-4 opacity-50 flex-shrink-0 text-[var(--app-primary-foreground)]" />
|
||||
<input
|
||||
type="text"
|
||||
className="session-search-input flex-1 bg-transparent border-none outline-none text-[var(--app-menu-foreground)] text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] p-0 placeholder:text-[var(--app-input-placeholder-foreground)] placeholder:opacity-60"
|
||||
placeholder="Search sessions…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session List with Grouping */}
|
||||
<div
|
||||
className="session-list-content overflow-y-auto flex-1 select-none p-2"
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
const distanceToBottom =
|
||||
el.scrollHeight - (el.scrollTop + el.clientHeight);
|
||||
if (distanceToBottom < 48 && hasMore && !isLoading) {
|
||||
onLoadMore?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasNoSessions ? (
|
||||
<div
|
||||
className="p-5 text-center text-[var(--app-secondary-foreground)]"
|
||||
style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--app-secondary-foreground)',
|
||||
}}
|
||||
>
|
||||
{searchQuery ? 'No matching sessions' : 'No sessions available'}
|
||||
</div>
|
||||
) : (
|
||||
groupSessionsByDate(sessions).map((group) => (
|
||||
<React.Fragment key={group.label}>
|
||||
<div className="session-group-label p-1 px-2 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em] font-medium [&:not(:first-child)]:mt-2">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="session-group flex flex-col gap-[2px]">
|
||||
{group.sessions.map((session) => {
|
||||
const sessionId =
|
||||
(session.id as string) ||
|
||||
(session.sessionId as string) ||
|
||||
'';
|
||||
const title =
|
||||
(session.title as string) ||
|
||||
(session.name as string) ||
|
||||
'Untitled';
|
||||
const lastUpdated =
|
||||
(session.lastUpdated as string) ||
|
||||
(session.startTime as string) ||
|
||||
'';
|
||||
const isActive = sessionId === currentSessionId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sessionId}
|
||||
className={`session-item flex items-center justify-between py-1.5 px-2 bg-transparent border-none rounded-md cursor-pointer text-left w-full text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] text-[var(--app-primary-foreground)] transition-colors duration-100 hover:bg-[var(--app-list-hover-background)] ${
|
||||
isActive
|
||||
? 'active bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] font-[600]'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectSession(sessionId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="session-item-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
|
||||
{title}
|
||||
</span>
|
||||
<span className="session-item-time opacity-60 text-[0.9em] flex-shrink-0 ml-3">
|
||||
{getTimeAgo(lastUpdated)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="p-2 text-center opacity-60 text-[0.9em]">
|
||||
{isLoading ? 'Loading…' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* AssistantMessage Component Styles
|
||||
* Pseudo-elements (::before) for bullet points and (::after) for timeline connectors
|
||||
*/
|
||||
|
||||
/* Bullet point indicator using ::before pseudo-element */
|
||||
.assistant-message-container.assistant-message-default::before,
|
||||
.assistant-message-container.assistant-message-success::before,
|
||||
.assistant-message-container.assistant-message-error::before,
|
||||
.assistant-message-container.assistant-message-warning::before,
|
||||
.assistant-message-container.assistant-message-loading::before {
|
||||
content: '\25cf';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
padding-top: 2px;
|
||||
font-size: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Default state - secondary foreground color */
|
||||
.assistant-message-container.assistant-message-default::before {
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
/* Success state - green bullet (maps to .ge) */
|
||||
.assistant-message-container.assistant-message-success::before {
|
||||
color: #74c991;
|
||||
}
|
||||
|
||||
/* Error state - red bullet (maps to .be) */
|
||||
.assistant-message-container.assistant-message-error::before {
|
||||
color: #c74e39;
|
||||
}
|
||||
|
||||
/* Warning state - yellow/orange bullet (maps to .ue) */
|
||||
.assistant-message-container.assistant-message-warning::before {
|
||||
color: #e1c08d;
|
||||
}
|
||||
|
||||
/* Loading state - static bullet (maps to .he) */
|
||||
.assistant-message-container.assistant-message-loading::before {
|
||||
color: var(--app-secondary-foreground);
|
||||
background-color: var(--app-secondary-background);
|
||||
}
|
||||
|
||||
.assistant-message-container.assistant-message-loading::after {
|
||||
display: none
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { MessageContent } from '../MessageContent.js';
|
||||
import './AssistantMessage.css';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
onFileClick?: (path: string) => void;
|
||||
status?: 'default' | 'success' | 'error' | 'warning' | 'loading';
|
||||
// When true, render without the left status bullet (no ::before dot)
|
||||
hideStatusIcon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AssistantMessage component - renders AI responses with Qwen Code styling
|
||||
* Supports different states: default, success, error, warning, loading
|
||||
*/
|
||||
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
content,
|
||||
timestamp: _timestamp,
|
||||
onFileClick,
|
||||
status = 'default',
|
||||
hideStatusIcon = false,
|
||||
}) => {
|
||||
// Empty content not rendered directly, avoid poor visual experience from only showing ::before dot
|
||||
if (!content || content.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map status to CSS class (only for ::before pseudo-element)
|
||||
const getStatusClass = () => {
|
||||
if (hideStatusIcon) {
|
||||
return '';
|
||||
}
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'assistant-message-success';
|
||||
case 'error':
|
||||
return 'assistant-message-error';
|
||||
case 'warning':
|
||||
return 'assistant-message-warning';
|
||||
case 'loading':
|
||||
return 'assistant-message-loading';
|
||||
default:
|
||||
return 'assistant-message-default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`qwen-message message-item assistant-message-container ${getStatusClass()}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
alignItems: 'flex-start',
|
||||
paddingLeft: '30px',
|
||||
userSelect: 'text',
|
||||
position: 'relative',
|
||||
// paddingTop: '8px',
|
||||
// paddingBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
width: '100%',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
}}
|
||||
>
|
||||
<MessageContent content={content} onFileClick={onFileClick} />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Styles for MarkdownRenderer component
|
||||
*/
|
||||
|
||||
.markdown-content {
|
||||
/* Base styles for markdown content */
|
||||
line-height: 1.6;
|
||||
color: var(--app-primary-foreground);
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 1.75em;
|
||||
border-bottom: 1px solid var(--app-primary-border-color);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid var(--app-primary-border-color);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.markdown-content h4 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-top: 0;
|
||||
/* margin-bottom: 1em; */
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
/* Ensure list markers are visible even with global CSS resets */
|
||||
.markdown-content ul {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.markdown-content ol {
|
||||
list-style-type: decimal;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
/* Nested list styles */
|
||||
.markdown-content ul ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
.markdown-content ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.markdown-content ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
.markdown-content ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
/* Style the marker explicitly so themes don't hide it */
|
||||
.markdown-content li::marker {
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.markdown-content li > p {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
margin: 0 0 1em;
|
||||
padding: 0 1em;
|
||||
border-left: 0.25em solid var(--app-primary-border-color);
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: var(--app-link-foreground, #007acc);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
color: var(--app-link-active-foreground, #005a9e);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
font-family: var(
|
||||
--app-monospace-font-family,
|
||||
'SF Mono',
|
||||
Monaco,
|
||||
'Cascadia Code',
|
||||
'Roboto Mono',
|
||||
Consolas,
|
||||
'Courier New',
|
||||
monospace
|
||||
);
|
||||
font-size: 0.9em;
|
||||
background-color: var(--app-code-background, rgba(0, 0, 0, 0.05));
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
border-radius: var(--corner-radius-small, 4px);
|
||||
padding: 0.2em 0.4em;
|
||||
white-space: pre-wrap; /* Support automatic line wrapping */
|
||||
word-break: break-word; /* Break words when necessary */
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
overflow-x: auto;
|
||||
background-color: var(--app-code-background, rgba(0, 0, 0, 0.05));
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
border-radius: var(--corner-radius-small, 4px);
|
||||
font-family: var(
|
||||
--app-monospace-font-family,
|
||||
'SF Mono',
|
||||
Monaco,
|
||||
'Cascadia Code',
|
||||
'Roboto Mono',
|
||||
Consolas,
|
||||
'Courier New',
|
||||
monospace
|
||||
);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
white-space: pre-wrap; /* Support automatic line wrapping */
|
||||
word-break: break-word; /* Break words when necessary */
|
||||
}
|
||||
|
||||
.markdown-content .file-path-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: var(
|
||||
--app-monospace-font-family,
|
||||
'SF Mono',
|
||||
Monaco,
|
||||
'Cascadia Code',
|
||||
'Roboto Mono',
|
||||
Consolas,
|
||||
'Courier New',
|
||||
monospace
|
||||
);
|
||||
font-size: 0.95em;
|
||||
color: var(--app-link-foreground, #007acc);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
|
||||
.markdown-content .file-path-link:hover {
|
||||
color: var(--app-link-active-foreground, #005a9e);
|
||||
}
|
||||
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--app-primary-border-color);
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: var(--app-secondary-background);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import type { Options as MarkdownItOptions } from 'markdown-it';
|
||||
import './MarkdownRenderer.css';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
onFileClick?: (filePath: string) => void;
|
||||
/** When false, do not convert file paths into clickable links. Default: true */
|
||||
enableFileLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular expressions for parsing content
|
||||
*/
|
||||
// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts
|
||||
const FILE_PATH_REGEX =
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
|
||||
// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7
|
||||
const FILE_PATH_WITH_LINES_REGEX =
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
|
||||
|
||||
/**
|
||||
* MarkdownRenderer component - renders markdown content with enhanced features
|
||||
*/
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
onFileClick,
|
||||
enableFileLinks = true,
|
||||
}) => {
|
||||
/**
|
||||
* Initialize markdown-it with plugins
|
||||
*/
|
||||
const getMarkdownInstance = (): MarkdownIt => {
|
||||
// Create markdown-it instance with options
|
||||
const md = new MarkdownIt({
|
||||
html: false, // Disable HTML for security
|
||||
xhtmlOut: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
} as MarkdownItOptions);
|
||||
|
||||
return md;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render markdown content to HTML
|
||||
*/
|
||||
const renderMarkdown = (): string => {
|
||||
try {
|
||||
const md = getMarkdownInstance();
|
||||
|
||||
// Process the markdown content
|
||||
let html = md.render(content);
|
||||
|
||||
// Post-process to add file path click handlers unless disabled
|
||||
if (enableFileLinks) {
|
||||
html = processFilePaths(html);
|
||||
}
|
||||
|
||||
return html;
|
||||
} catch (error) {
|
||||
console.error('Error rendering markdown:', error);
|
||||
// Fallback to plain text if markdown rendering fails
|
||||
return escapeHtml(content);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML characters for security
|
||||
*/
|
||||
const escapeHtml = (unsafe: string): string =>
|
||||
unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* Process file paths in HTML to make them clickable
|
||||
*/
|
||||
const processFilePaths = (html: string): string => {
|
||||
// If DOM is not available, bail out to avoid breaking SSR
|
||||
if (typeof document === 'undefined') {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Build non-global variants to avoid .test() statefulness
|
||||
const FILE_PATH_NO_G = new RegExp(
|
||||
FILE_PATH_REGEX.source,
|
||||
FILE_PATH_REGEX.flags.replace('g', ''),
|
||||
);
|
||||
const FILE_PATH_WITH_LINES_NO_G = new RegExp(
|
||||
FILE_PATH_WITH_LINES_REGEX.source,
|
||||
FILE_PATH_WITH_LINES_REGEX.flags.replace('g', ''),
|
||||
);
|
||||
// Match a bare file name like README.md (no leading slash)
|
||||
const BARE_FILE_REGEX =
|
||||
/[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)/i;
|
||||
|
||||
// Parse HTML into a DOM tree so we don't replace inside attributes
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
|
||||
const union = new RegExp(
|
||||
`${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}|${BARE_FILE_REGEX.source}`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
// Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line)
|
||||
const normalizePathAndLine = (
|
||||
raw: string,
|
||||
): { displayText: string; dataPath: string } => {
|
||||
const displayText = raw;
|
||||
let base = raw;
|
||||
// Extract hash fragment like #12, #L12 or #12-34 and keep only the first number
|
||||
const hashIndex = raw.indexOf('#');
|
||||
if (hashIndex >= 0) {
|
||||
const frag = raw.slice(hashIndex + 1);
|
||||
// Accept L12, 12 or 12-34
|
||||
const m = frag.match(/^L?(\d+)(?:-\d+)?$/i);
|
||||
if (m) {
|
||||
const line = parseInt(m[1], 10);
|
||||
base = raw.slice(0, hashIndex);
|
||||
return { displayText, dataPath: `${base}:${line}` };
|
||||
}
|
||||
}
|
||||
return { displayText, dataPath: base };
|
||||
};
|
||||
|
||||
const makeLink = (text: string) => {
|
||||
const link = document.createElement('a');
|
||||
// Pass base path (with optional :line) to the handler; keep the full text as label
|
||||
const { dataPath } = normalizePathAndLine(text);
|
||||
link.className = 'file-path-link';
|
||||
link.textContent = text;
|
||||
link.setAttribute('href', '#');
|
||||
link.setAttribute('title', `Open ${text}`);
|
||||
// Carry file path via data attribute; click handled by event delegation
|
||||
link.setAttribute('data-file-path', dataPath);
|
||||
return link;
|
||||
};
|
||||
|
||||
const upgradeAnchorIfFilePath = (a: HTMLAnchorElement) => {
|
||||
const href = a.getAttribute('href') || '';
|
||||
const text = (a.textContent || '').trim();
|
||||
|
||||
// Helper: identify dot-chained code refs (e.g. vscode.commands.register)
|
||||
// but DO NOT treat filenames/paths as code refs.
|
||||
const isCodeReference = (str: string): boolean => {
|
||||
if (BARE_FILE_REGEX.test(str)) {
|
||||
return false; // looks like a filename
|
||||
}
|
||||
if (/[/\\]/.test(str)) {
|
||||
return false; // contains a path separator
|
||||
}
|
||||
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||
return codeRefPattern.test(str);
|
||||
};
|
||||
|
||||
// If linkify turned a bare filename (e.g. README.md) into http://<filename>, convert it back
|
||||
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
|
||||
if (httpMatch) {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const host = url.hostname || '';
|
||||
const pathname = url.pathname || '';
|
||||
const noPath = pathname === '' || pathname === '/';
|
||||
|
||||
// Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md)
|
||||
if (
|
||||
noPath &&
|
||||
BARE_FILE_REGEX.test(text) &&
|
||||
host.toLowerCase() === text.toLowerCase()
|
||||
) {
|
||||
const { dataPath } = normalizePathAndLine(text);
|
||||
a.classList.add('file-path-link');
|
||||
a.setAttribute('href', '#');
|
||||
a.setAttribute('title', `Open ${text}`);
|
||||
a.setAttribute('data-file-path', dataPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: host itself looks like a filename (rare but happens), use it
|
||||
if (noPath && BARE_FILE_REGEX.test(host)) {
|
||||
const { dataPath } = normalizePathAndLine(host);
|
||||
a.classList.add('file-path-link');
|
||||
a.setAttribute('href', '#');
|
||||
a.setAttribute('title', `Open ${text || host}`);
|
||||
a.setAttribute('data-file-path', dataPath);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through; unparseable URL
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore other external protocols
|
||||
if (/^(https?|mailto|ftp|data):/i.test(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = href || text;
|
||||
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(candidate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
|
||||
FILE_PATH_NO_G.test(candidate)
|
||||
) {
|
||||
const { dataPath } = normalizePathAndLine(candidate);
|
||||
a.classList.add('file-path-link');
|
||||
a.setAttribute('href', '#');
|
||||
a.setAttribute('title', `Open ${text || href}`);
|
||||
a.setAttribute('data-file-path', dataPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bare file name or relative path (e.g. README.md or docs/README.md)
|
||||
if (BARE_FILE_REGEX.test(candidate)) {
|
||||
const { dataPath } = normalizePathAndLine(candidate);
|
||||
a.classList.add('file-path-link');
|
||||
a.setAttribute('href', '#');
|
||||
a.setAttribute('title', `Open ${text || href}`);
|
||||
a.setAttribute('data-file-path', dataPath);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper: identify dot-chained code refs (e.g. vscode.commands.register)
|
||||
// but DO NOT treat filenames/paths as code refs.
|
||||
const isCodeReference = (str: string): boolean => {
|
||||
if (BARE_FILE_REGEX.test(str)) {
|
||||
return false; // looks like a filename
|
||||
}
|
||||
if (/[/\\]/.test(str)) {
|
||||
return false; // contains a path separator
|
||||
}
|
||||
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||
return codeRefPattern.test(str);
|
||||
};
|
||||
|
||||
const walk = (node: Node) => {
|
||||
// Do not transform inside existing anchors
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName.toLowerCase() === 'a') {
|
||||
upgradeAnchorIfFilePath(el as HTMLAnchorElement);
|
||||
return; // Don't descend into <a>
|
||||
}
|
||||
// Avoid transforming inside code/pre blocks
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === 'code' || tag === 'pre') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (let child = node.firstChild; child; ) {
|
||||
const next = child.nextSibling; // child may be replaced
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.nodeValue || '';
|
||||
union.lastIndex = 0;
|
||||
const hasMatch = union.test(text);
|
||||
union.lastIndex = 0;
|
||||
if (hasMatch) {
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = union.exec(text))) {
|
||||
const matchText = m[0];
|
||||
const idx = m.index;
|
||||
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(matchText)) {
|
||||
// Just add the text as-is without creating a link
|
||||
if (idx > lastIndex) {
|
||||
frag.appendChild(
|
||||
document.createTextNode(text.slice(lastIndex, idx)),
|
||||
);
|
||||
}
|
||||
frag.appendChild(document.createTextNode(matchText));
|
||||
lastIndex = idx + matchText.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (idx > lastIndex) {
|
||||
frag.appendChild(
|
||||
document.createTextNode(text.slice(lastIndex, idx)),
|
||||
);
|
||||
}
|
||||
frag.appendChild(makeLink(matchText));
|
||||
lastIndex = idx + matchText.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
node.replaceChild(frag, child);
|
||||
}
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
walk(child);
|
||||
}
|
||||
child = next;
|
||||
}
|
||||
};
|
||||
|
||||
walk(container);
|
||||
return container.innerHTML;
|
||||
};
|
||||
|
||||
// Event delegation: intercept clicks on generated file-path links
|
||||
const handleContainerClick = (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => {
|
||||
// If file links disabled, do nothing
|
||||
if (!enableFileLinks) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find nearest anchor with our marker class
|
||||
const anchor = (target.closest &&
|
||||
target.closest('a.file-path-link')) as HTMLAnchorElement | null;
|
||||
if (anchor) {
|
||||
const filePath = anchor.getAttribute('data-file-path');
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileClick?.(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: intercept "http://README.md" style links that slipped through
|
||||
const anyAnchor = (target.closest &&
|
||||
target.closest('a')) as HTMLAnchorElement | null;
|
||||
if (!anyAnchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anyAnchor.getAttribute('href') || '';
|
||||
if (!/^https?:\/\//i.test(href)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const host = url.hostname || '';
|
||||
const path = url.pathname || '';
|
||||
const noPath = path === '' || path === '/';
|
||||
|
||||
// Basic bare filename heuristic on the host part (e.g. README.md)
|
||||
if (noPath && /\.[a-z0-9]+$/i.test(host)) {
|
||||
// Prefer the readable text content if it looks like a file
|
||||
const text = (anyAnchor.textContent || '').trim();
|
||||
const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileClick?.(candidate);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-content"
|
||||
onClick={handleContainerClick}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown() }}
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js';
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string;
|
||||
onFileClick?: (filePath: string) => void;
|
||||
enableFileLinks?: boolean;
|
||||
}
|
||||
|
||||
export const MessageContent: React.FC<MessageContentProps> = ({
|
||||
content,
|
||||
onFileClick,
|
||||
enableFileLinks,
|
||||
}) => (
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={enableFileLinks}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { MessageContent } from './MessageContent.js';
|
||||
|
||||
interface ThinkingMessageProps {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
onFileClick?: (path: string) => void;
|
||||
}
|
||||
|
||||
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
|
||||
content,
|
||||
timestamp: _timestamp,
|
||||
onFileClick,
|
||||
}) => (
|
||||
<div className="qwen-message thinking-message flex gap-0 items-start text-left py-2 flex-col relative opacity-80 italic pl-6 animate-[fadeIn_0.2s_ease-in]">
|
||||
<div
|
||||
className="inline-block my-1 relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'var(--app-list-hover-background, rgba(100, 100, 255, 0.1))',
|
||||
border: '1px solid rgba(100, 100, 255, 0.3)',
|
||||
borderRadius: 'var(--corner-radius-medium)',
|
||||
padding: 'var(--app-spacing-medium)',
|
||||
color: 'var(--app-primary-foreground)',
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 mr-2">
|
||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0s]"></span>
|
||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.2s]"></span>
|
||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.4s]"></span>
|
||||
</span>
|
||||
<MessageContent content={content} onFileClick={onFileClick} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { MessageContent } from './MessageContent.js';
|
||||
|
||||
interface FileContext {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
|
||||
interface UserMessageProps {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
onFileClick?: (path: string) => void;
|
||||
fileContext?: FileContext;
|
||||
}
|
||||
|
||||
export const UserMessage: React.FC<UserMessageProps> = ({
|
||||
content,
|
||||
timestamp: _timestamp,
|
||||
onFileClick,
|
||||
fileContext,
|
||||
}) => {
|
||||
// Generate display text for file context
|
||||
const getFileContextDisplay = () => {
|
||||
if (!fileContext) {
|
||||
return null;
|
||||
}
|
||||
const { fileName, startLine, endLine } = fileContext;
|
||||
if (startLine && endLine) {
|
||||
return startLine === endLine
|
||||
? `${fileName}#${startLine}`
|
||||
: `${fileName}#${startLine}-${endLine}`;
|
||||
}
|
||||
return fileName;
|
||||
};
|
||||
|
||||
const fileContextDisplay = getFileContextDisplay();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="qwen-message user-message-container flex gap-0 my-1 items-start text-left flex-col relative"
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<div
|
||||
className="inline-block relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
|
||||
style={{
|
||||
border: '1px solid var(--app-input-border)',
|
||||
borderRadius: 'var(--corner-radius-medium)',
|
||||
backgroundColor: 'var(--app-input-background)',
|
||||
padding: '4px 6px',
|
||||
color: 'var(--app-primary-foreground)',
|
||||
}}
|
||||
>
|
||||
{/* For user messages, do NOT convert filenames to clickable links */}
|
||||
<MessageContent
|
||||
content={content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File context indicator */}
|
||||
{fileContextDisplay && (
|
||||
<div className="mt-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mr inline-flex items-center py-0 pr-2 gap-1 rounded-sm cursor-pointer relative opacity-50"
|
||||
onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
fileContext && onFileClick?.(fileContext.filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="gr"
|
||||
title={fileContextDisplay}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--app-secondary-foreground)',
|
||||
}}
|
||||
>
|
||||
{fileContextDisplay}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
interface InterruptedMessageProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// A lightweight status line similar to WaitingMessage but without the left status icon.
|
||||
export const InterruptedMessage: React.FC<InterruptedMessageProps> = ({
|
||||
text = 'Interrupted',
|
||||
}) => (
|
||||
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85">
|
||||
<div className="interrupted-item w-full relative">
|
||||
<span className="opacity-70 italic">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@import url('../Assistant/AssistantMessage.css');
|
||||
|
||||
/* Subtle shimmering highlight across the loading text */
|
||||
@keyframes waitingMessageShimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text-shimmer {
|
||||
/* Use the theme foreground as the base color, with a moving light band */
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--app-secondary-foreground) 0%,
|
||||
var(--app-secondary-foreground) 40%,
|
||||
rgba(255, 255, 255, 0.95) 50%,
|
||||
var(--app-secondary-foreground) 60%,
|
||||
var(--app-secondary-foreground) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent; /* text color comes from the gradient */
|
||||
animation: waitingMessageShimmer 1.6s linear infinite;
|
||||
}
|
||||
|
||||
.interrupted-item::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import './WaitingMessage.css';
|
||||
import { WITTY_LOADING_PHRASES } from '../../../../constants/loadingMessages.js';
|
||||
|
||||
interface WaitingMessageProps {
|
||||
loadingMessage: string;
|
||||
}
|
||||
|
||||
// Rotate message every few seconds while waiting
|
||||
const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request
|
||||
|
||||
export const WaitingMessage: React.FC<WaitingMessageProps> = ({
|
||||
loadingMessage,
|
||||
}) => {
|
||||
// Build a phrase list that starts with the provided message (if any), then witty fallbacks
|
||||
const phrases = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
const list: string[] = [];
|
||||
if (loadingMessage && loadingMessage.trim()) {
|
||||
list.push(loadingMessage);
|
||||
set.add(loadingMessage);
|
||||
}
|
||||
for (const p of WITTY_LOADING_PHRASES) {
|
||||
if (!set.has(p)) {
|
||||
list.push(p);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [loadingMessage]);
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
// Reset to the first phrase whenever the incoming message changes
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
}, [phrases]);
|
||||
|
||||
// Periodically rotate to a different phrase
|
||||
useEffect(() => {
|
||||
if (phrases.length <= 1) {
|
||||
return;
|
||||
}
|
||||
const id = setInterval(() => {
|
||||
setIndex((prev) => {
|
||||
// pick a different random index to avoid immediate repeats
|
||||
let next = Math.floor(Math.random() * phrases.length);
|
||||
if (phrases.length > 1) {
|
||||
let guard = 0;
|
||||
while (next === prev && guard < 5) {
|
||||
next = Math.floor(Math.random() * phrases.length);
|
||||
guard++;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, ROTATE_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [phrases]);
|
||||
|
||||
return (
|
||||
<div className="waiting-message-outer flex gap-0 items-start text-left py-2 flex-col opacity-85">
|
||||
{/* Use the same left status icon (pseudo-element) style as assistant-message-container */}
|
||||
<div className="assistant-message-container assistant-message-loading waiting-message-inner w-full items-start pl-[30px] relative">
|
||||
<span className="waiting-message-text opacity-70 italic loading-text-shimmer">
|
||||
{phrases[index]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { UserMessage } from './UserMessage.js';
|
||||
export { AssistantMessage } from './Assistant/AssistantMessage.js';
|
||||
export { ThinkingMessage } from './ThinkingMessage.js';
|
||||
export { WaitingMessage } from './Waiting/WaitingMessage.js';
|
||||
export { InterruptedMessage } from './Waiting/InterruptedMessage.js';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user