Merge branch 'main' into feat/skills

This commit is contained in:
tanzhenxin
2025-12-11 14:50:07 +08:00
149 changed files with 22354 additions and 210 deletions

View File

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

View File

@@ -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'
],
},
],

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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",

View File

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

View 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',
});
});
});
});

View File

@@ -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;
}

View File

@@ -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,
},
},
});
});
});
});

View File

@@ -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);
}
}
/**

View File

@@ -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);
}

View File

@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'in_progress',
status: 'pending',
title: 'read_file',
content: [],
locations: [],

View File

@@ -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.
*/

View File

@@ -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,
},
});
});
});
});

View File

@@ -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,
});
}

View File

@@ -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',

View File

@@ -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 },

View File

@@ -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';
}
/**

View File

@@ -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');
});

View File

@@ -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,
},
};
},

View File

@@ -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} />,

View File

@@ -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>,
);
}

View File

@@ -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
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;

View 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.`,
);
}
};

View File

@@ -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",

View File

@@ -58,6 +58,7 @@ export type {
SubAgentStartEvent,
SubAgentRoundEvent,
SubAgentStreamTextEvent,
SubAgentUsageEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentFinishEvent,

View File

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

View File

@@ -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', () => {

View File

@@ -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) {

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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).

View File

@@ -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()]);
}
}

View File

@@ -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',

View File

@@ -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"
}
}

View 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: {},
},
};

View 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;
}
}

View 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',
};
}
}

View 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);
}
}
}

View 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.`,
};
}

View 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;
}
}

View 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);
}

View 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;

View 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)
];

View File

@@ -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);
}
}

View File

@@ -40,6 +40,9 @@ vi.mock('vscode', () => ({
},
showTextDocument: vi.fn(),
showWorkspaceFolderPick: vi.fn(),
registerWebviewPanelSerializer: vi.fn(() => ({
dispose: vi.fn(),
})),
},
workspace: {
workspaceFolders: [],

View File

@@ -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}`);

View File

@@ -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: [] };
},

View File

@@ -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];

View 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();
}
}

View 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}`);
}
}
}

View 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',
},
};
}
}
}

View 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;
}
}

View 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}`;
}
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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));
}
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View File

@@ -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;
}
}
}

View 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;

View 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,
};
}

View 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
}

View File

@@ -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;
}

View 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;
}

View 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;
}

View 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>
);
};

View 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();
}
}

View 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;
}
}

View 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>`;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

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

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
/**
* 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',
}}
/>
);
};

View File

@@ -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}
/>
);

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

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