From 8e64c5acaf751fd4db3f32c9ce5dfcfdb582a7c6 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 24 Dec 2025 01:09:21 +0800 Subject: [PATCH] feat(vscode-ide-companion): support context left --- packages/cli/src/acp-integration/acpAgent.ts | 12 +++ packages/cli/src/acp-integration/schema.ts | 15 ++++ .../src/acp-integration/session/Session.ts | 42 ++++++++++ .../src/services/acpConnection.ts | 7 ++ .../src/services/acpSessionManager.ts | 5 ++ .../src/services/qwenAgentManager.ts | 46 ++++++++++ .../src/services/qwenSessionUpdateHandler.ts | 41 ++++++++- .../src/types/acpTypes.ts | 32 ++++++- .../src/types/chatTypes.ts | 17 +++- .../vscode-ide-companion/src/webview/App.tsx | 48 ++++++++++- .../src/webview/WebViewProvider.ts | 14 ++++ .../src/webview/components/Tooltip.tsx | 61 ++++++++++++++ .../webview/components/layout/InputForm.tsx | 83 +++++++++++++++++++ .../webview/handlers/SessionMessageHandler.ts | 12 +++ .../src/webview/hooks/useWebViewMessages.ts | 56 ++++++++++++- .../src/webview/styles/tailwind.css | 22 +++++ 16 files changed, 507 insertions(+), 6 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/components/Tooltip.tsx diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 91ce53cb0..6d92db8dc 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -19,6 +19,7 @@ import { type Config, type ConversationRecord, type DeviceAuthorizationData, + tokenLimit, } from '@qwen-code/qwen-code-core'; import type { ApprovalModeValue } from './schema.js'; import * as acp from './acp.js'; @@ -100,6 +101,14 @@ class GeminiAgent { })); const version = process.env['CLI_VERSION'] || process.version; + const modelName = this.config.getModel(); + const modelInfo = + modelName && modelName.length > 0 + ? { + name: modelName, + contextLimit: tokenLimit(modelName), + } + : undefined; return { protocolVersion: acp.PROTOCOL_VERSION, @@ -113,6 +122,7 @@ class GeminiAgent { currentModeId: currentApprovalMode as ApprovalModeValue, availableModes, }, + modelInfo, agentCapabilities: { loadSession: true, promptCapabilities: { @@ -347,6 +357,8 @@ class GeminiAgent { await session.sendAvailableCommandsUpdate(); }, 0); + await session.announceCurrentModel(true); + if (conversation && conversation.messages) { await session.replayHistory(conversation.messages); } diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index a557c5197..0eccb2786 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -93,6 +93,7 @@ export type ModeInfo = z.infer; export type ModesData = z.infer; export type AgentInfo = z.infer; +export type ModelInfo = z.infer; export type PromptCapabilities = z.infer; @@ -417,11 +418,17 @@ export const agentInfoSchema = z.object({ version: z.string(), }); +export const modelInfoSchema = z.object({ + name: z.string(), + contextLimit: z.number().optional().nullable(), +}); + export const initializeResponseSchema = z.object({ agentCapabilities: agentCapabilitiesSchema, agentInfo: agentInfoSchema, authMethods: z.array(authMethodSchema), modes: modesDataSchema, + modelInfo: modelInfoSchema.optional(), protocolVersion: z.number(), }); @@ -514,6 +521,13 @@ export const currentModeUpdateSchema = z.object({ export type CurrentModeUpdate = z.infer; +export const currentModelUpdateSchema = z.object({ + sessionUpdate: z.literal('current_model_update'), + model: modelInfoSchema, +}); + +export type CurrentModelUpdate = z.infer; + export const sessionUpdateSchema = z.union([ z.object({ content: contentBlockSchema, @@ -555,6 +569,7 @@ export const sessionUpdateSchema = z.union([ sessionUpdate: z.literal('plan'), }), currentModeUpdateSchema, + currentModelUpdateSchema, availableCommandsUpdateSchema, ]); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 1d90ed20b..50a6ca6b7 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -33,6 +33,7 @@ import { UserPromptEvent, TodoWriteTool, ExitPlanModeTool, + tokenLimit, } from '@qwen-code/qwen-code-core'; import * as acp from '../acp.js'; @@ -52,6 +53,7 @@ import type { SetModeResponse, ApprovalModeValue, CurrentModeUpdate, + CurrentModelUpdate, } from '../schema.js'; import { isSlashCommand } from '../../ui/utils/commandUtils.js'; @@ -86,6 +88,10 @@ export class Session implements SessionContext { private readonly toolCallEmitter: ToolCallEmitter; private readonly planEmitter: PlanEmitter; private readonly messageEmitter: MessageEmitter; + private lastAnnouncedModel: { + name: string; + contextLimit?: number | null; + } | null = null; // Implement SessionContext interface readonly sessionId: string; @@ -191,6 +197,8 @@ export class Session implements SessionContext { parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); } + await this.sendCurrentModelUpdate(); + let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { @@ -379,6 +387,40 @@ export class Session implements SessionContext { await this.sendUpdate(update); } + async announceCurrentModel(force: boolean = false): Promise { + await this.sendCurrentModelUpdate(force); + } + + private async sendCurrentModelUpdate(force: boolean = false): Promise { + const modelName = this.config.getModel(); + if (!modelName) { + return; + } + + const contextLimit = tokenLimit(modelName); + + if ( + !force && + this.lastAnnouncedModel && + this.lastAnnouncedModel.name === modelName && + this.lastAnnouncedModel.contextLimit === contextLimit + ) { + return; + } + + this.lastAnnouncedModel = { name: modelName, contextLimit }; + + const update: CurrentModelUpdate = { + sessionUpdate: 'current_model_update', + model: { + name: modelName, + contextLimit, + }, + }; + + await this.sendUpdate(update); + } + private async runTool( abortSignal: AbortSignal, promptId: string, diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 4b2c4028b..360d19d7d 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -146,6 +146,8 @@ export class AcpConnection { console.error( `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, ); + // Clear pending requests when process exits + this.pendingRequests.clear(); }); // Wait for process to start @@ -287,6 +289,11 @@ export class AcpConnection { * @returns Response */ async sendPrompt(prompt: string): Promise { + // Verify connection is still active before sending request + if (!this.isConnected) { + throw new Error('ACP connection is not active'); + } + return this.sessionManager.sendPrompt( prompt, this.child, diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts index e2055a3a2..d52d1cce9 100644 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -231,6 +231,11 @@ export class AcpSessionManager { throw new Error('No active ACP session'); } + // Check if child process is still alive before sending the request + if (!child || child.killed) { + throw new Error('ACP child process is not available'); + } + return await this.sendRequest( AGENT_METHODS.session_prompt, { diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index e60ee3a21..5799f3d51 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -17,6 +17,7 @@ import type { PlanEntry, ToolCallUpdateData, QwenAgentCallbacks, + UsageStatsPayload, } from '../types/chatTypes.js'; import { QwenConnectionHandler, @@ -177,6 +178,23 @@ export class QwenAgentManager { availableModes: modes.availableModes, }); } + + const modelInfo = obj['modelInfo'] as + | { + name?: string; + contextLimit?: number | null; + } + | undefined; + if ( + modelInfo && + typeof modelInfo.name === 'string' && + this.callbacks.onModelInfo + ) { + this.callbacks.onModelInfo({ + name: modelInfo.name, + contextLimit: modelInfo.contextLimit, + }); + } } catch (err) { console.warn('[QwenAgentManager] onInitialized parse error:', err); } @@ -209,6 +227,16 @@ export class QwenAgentManager { * @param message - Message content */ async sendMessage(message: string): Promise { + // Validate the current session before sending the message + const isValid = await this.validateCurrentSession(); + if (!isValid) { + console.warn( + '[QwenAgentManager] Current session is invalid, creating new session', + ); + const workingDir = this.currentWorkingDir; + await this.createNewSession(workingDir); + } + await this.connection.sendPrompt(message); } @@ -1257,6 +1285,24 @@ export class QwenAgentManager { this.sessionUpdateHandler.updateCallbacks(this.callbacks); } + /** + * Register callback for usage metadata updates + */ + onUsageUpdate(callback: (stats: UsageStatsPayload) => void): void { + this.callbacks.onUsageUpdate = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + + /** + * Register callback for model info updates + */ + onModelInfo( + callback: (info: { name: string; contextLimit?: number | null }) => void, + ): void { + this.callbacks.onModelInfo = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + /** * Disconnect */ diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index d7b24bb2c..894443931 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -10,9 +10,16 @@ * Handles session updates from ACP and dispatches them to appropriate callbacks */ -import type { AcpSessionUpdate } from '../types/acpTypes.js'; +import type { + AcpSessionUpdate, + ModelInfo, + SessionUpdateMeta, +} from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; -import type { QwenAgentCallbacks } from '../types/chatTypes.js'; +import type { + QwenAgentCallbacks, + UsageStatsPayload, +} from '../types/chatTypes.js'; /** * Qwen Session Update Handler class @@ -57,6 +64,7 @@ export class QwenSessionUpdateHandler { if (update.content?.text && this.callbacks.onStreamChunk) { this.callbacks.onStreamChunk(update.content.text); } + this.emitUsageMeta(update._meta); break; case 'agent_thought_chunk': @@ -71,6 +79,7 @@ export class QwenSessionUpdateHandler { this.callbacks.onStreamChunk(update.content.text); } } + this.emitUsageMeta(update._meta); break; case 'tool_call': { @@ -155,9 +164,37 @@ export class QwenSessionUpdateHandler { break; } + case 'current_model_update': { + this.emitModelInfo((update as unknown as { model?: ModelInfo }).model); + break; + } + default: console.log('[QwenAgentManager] Unhandled session update type'); break; } } + + private emitUsageMeta(meta?: SessionUpdateMeta): void { + if (!meta || !this.callbacks.onUsageUpdate) { + return; + } + + const payload: UsageStatsPayload = { + usage: meta.usage || undefined, + durationMs: meta.durationMs ?? undefined, + model: meta.model ?? undefined, + tokenLimit: meta.tokenLimit ?? undefined, + }; + + this.callbacks.onUsageUpdate(payload); + } + + private emitModelInfo(model?: ModelInfo): void { + if (!model || !this.callbacks.onModelInfo) { + return; + } + + this.callbacks.onModelInfo(model); + } } diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 5ddbfd06d..1f22adf8b 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -48,6 +48,26 @@ export interface ContentBlock { uri?: string; } +export interface UsageMetadata { + promptTokens?: number | null; + completionTokens?: number | null; + thoughtsTokens?: number | null; + totalTokens?: number | null; + cachedTokens?: number | null; +} + +export interface SessionUpdateMeta { + usage?: UsageMetadata | null; + durationMs?: number | null; + model?: string | null; + tokenLimit?: number | null; +} + +export interface ModelInfo { + name: string; + contextLimit?: number | null; +} + export interface UserMessageChunkUpdate extends BaseSessionUpdate { update: { sessionUpdate: 'user_message_chunk'; @@ -59,6 +79,7 @@ export interface AgentMessageChunkUpdate extends BaseSessionUpdate { update: { sessionUpdate: 'agent_message_chunk'; content: ContentBlock; + _meta?: SessionUpdateMeta; }; } @@ -66,6 +87,7 @@ export interface AgentThoughtChunkUpdate extends BaseSessionUpdate { update: { sessionUpdate: 'agent_thought_chunk'; content: ContentBlock; + _meta?: SessionUpdateMeta; }; } @@ -166,6 +188,13 @@ export interface CurrentModeUpdate extends BaseSessionUpdate { }; } +export interface CurrentModelUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'current_model_update'; + model: ModelInfo; + }; +} + // Authenticate update (sent by agent during authentication process) export interface AuthenticateUpdateNotification { _meta: { @@ -180,7 +209,8 @@ export type AcpSessionUpdate = | ToolCallUpdate | ToolCallStatusUpdate | PlanUpdate - | CurrentModeUpdate; + | CurrentModeUpdate + | CurrentModelUpdate; // Permission request (simplified version, use schema.RequestPermissionRequest for validation) export interface AcpPermissionRequest { diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 4cffd4ebc..8cbe26a46 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -3,7 +3,7 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ -import type { AcpPermissionRequest } from './acpTypes.js'; +import type { AcpPermissionRequest, ModelInfo } from './acpTypes.js'; import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export interface ChatMessage { @@ -28,6 +28,19 @@ export interface ToolCallUpdateData { locations?: Array<{ path: string; line?: number | null }>; } +export interface UsageStatsPayload { + usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + thoughtsTokens?: number | null; + totalTokens?: number | null; + cachedTokens?: number | null; + } | null; + durationMs?: number | null; + model?: string | null; + tokenLimit?: number | null; +} + export interface QwenAgentCallbacks { onMessage?: (message: ChatMessage) => void; onStreamChunk?: (chunk: string) => void; @@ -45,6 +58,8 @@ export interface QwenAgentCallbacks { }>; }) => void; onModeChanged?: (modeId: ApprovalModeValue) => void; + onUsageUpdate?: (stats: UsageStatsPayload) => void; + onModelInfo?: (info: ModelInfo) => void; } export interface ToolCallUpdate { diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 5eacdabf0..77e845f89 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -45,7 +45,11 @@ import { SessionSelector } from './components/layout/SessionSelector.js'; import { FileIcon, UserIcon } from './components/icons/index.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; -import type { PlanEntry } from '../types/chatTypes.js'; +import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; +import { + DEFAULT_TOKEN_LIMIT, + tokenLimit, +} from '@qwen-code/qwen-code-core/src/core/tokenLimits.js'; export const App: React.FC = () => { const vscode = useVSCode(); @@ -70,6 +74,11 @@ export const App: React.FC = () => { const [planEntries, setPlanEntries] = useState([]); const [isAuthenticated, setIsAuthenticated] = useState(null); const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading + const [modelInfo] = useState<{ + name: string; + contextLimit?: number | null; + } | null>(null); + const [usageStats, setUsageStats] = useState(null); const messagesEndRef = useRef( null, ) as React.RefObject; @@ -160,6 +169,41 @@ export const App: React.FC = () => { const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); + const contextUsage = useMemo(() => { + if (!usageStats && !modelInfo) { + return null; + } + + const modelName = + (usageStats?.model && typeof usageStats.model === 'string' + ? usageStats.model + : undefined) ?? modelInfo?.name; + + const derivedLimit = + modelName && modelName.length > 0 ? tokenLimit(modelName) : undefined; + + const limit = + usageStats?.tokenLimit ?? + modelInfo?.contextLimit ?? + derivedLimit ?? + DEFAULT_TOKEN_LIMIT; + + const used = usageStats?.usage?.promptTokens ?? 0; + if (typeof limit !== 'number' || limit <= 0 || used < 0) { + return null; + } + const percentLeft = Math.max( + 0, + Math.min(100, Math.round(((limit - used) / limit) * 100)), + ); + return { + percentLeft, + usedTokens: used, + tokenLimit: limit, + model: modelName ?? undefined, + }; + }, [usageStats, modelInfo]); + // Track a lightweight signature of workspace files to detect content changes even when length is unchanged const workspaceFilesSignature = useMemo( () => @@ -248,6 +292,7 @@ export const App: React.FC = () => { setInputText, setEditMode, setIsAuthenticated, + setUsageStats: (stats) => setUsageStats(stats ?? null), }); // Auto-scroll handling: keep the view pinned to bottom when new content arrives, @@ -760,6 +805,7 @@ export const App: React.FC = () => { activeFileName={fileContext.activeFileName} activeSelection={fileContext.activeSelection} skipAutoActiveContext={skipAutoActiveContext} + contextUsage={contextUsage} onInputChange={setInputText} onCompositionStart={() => setIsComposing(true)} onCompositionEnd={() => setIsComposing(false)} diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 4ab55283c..5aa92c0fb 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -118,6 +118,20 @@ export class WebViewProvider { }); }); + this.agentManager.onUsageUpdate((stats) => { + this.sendMessageToWebView({ + type: 'usageStats', + data: stats, + }); + }); + + this.agentManager.onModelInfo((info) => { + this.sendMessageToWebView({ + type: 'modelInfo', + data: info, + }); + }); + // Setup end-turn handler from ACP stopReason notifications this.agentManager.onEndTurn((reason) => { // Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere diff --git a/packages/vscode-ide-companion/src/webview/components/Tooltip.tsx b/packages/vscode-ide-companion/src/webview/components/Tooltip.tsx new file mode 100644 index 000000000..1ee10c000 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/Tooltip.tsx @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +interface TooltipProps { + children: React.ReactNode; + content: React.ReactNode; + position?: 'top' | 'bottom' | 'left' | 'right'; +} + +export const Tooltip: React.FC = ({ + children, + content, + position = 'top', +}) => ( +
+
+ {children} +
+ {content} +
+
+
+
+); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 6bd3289af..88f2ec1a3 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -21,6 +21,7 @@ import { CompletionMenu } from '../layout/CompletionMenu.js'; import type { CompletionItem } from '../../../types/completionItemTypes.js'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; +import { Tooltip } from '../Tooltip.js'; interface InputFormProps { inputText: string; @@ -36,6 +37,12 @@ interface InputFormProps { activeSelection: { startLine: number; endLine: number } | null; // Whether to auto-load the active editor selection/path into context skipAutoActiveContext: boolean; + contextUsage: { + percentLeft: number; + usedTokens: number; + tokenLimit: number; + model?: string; + } | null; onInputChange: (text: string) => void; onCompositionStart: () => void; onCompositionEnd: () => void; @@ -96,6 +103,7 @@ export const InputForm: React.FC = ({ activeFileName, activeSelection, skipAutoActiveContext, + contextUsage, onInputChange, onCompositionStart, onCompositionEnd, @@ -143,6 +151,78 @@ export const InputForm: React.FC = ({ ? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected` : ''; + const renderContextIndicator = () => { + if (!contextUsage) { + return null; + } + + // Calculate used percentage for the progress indicator + // contextUsage.percentLeft is the percentage remaining, so 100 - percentLeft = percent used + const percentUsed = 100 - contextUsage.percentLeft; + const percentFormatted = Math.max( + 0, + Math.min(100, Math.round(percentUsed)), + ); + const radius = 9; + const circumference = 2 * Math.PI * radius; + // To show the used portion, we need to offset the unused portion + // If 20% is used, we want to show 20% filled, so offset the remaining 80% + const dashOffset = ((100 - percentUsed) / 100) * circumference; + const formatNumber = (value: number) => { + if (value >= 1000) { + return `${(Math.round((value / 1000) * 10) / 10).toFixed(1)}k`; + } + return Math.round(value).toLocaleString(); + }; + + // Create tooltip content with proper formatting + const tooltipContent = ( +
+
+ {percentFormatted}% • {formatNumber(contextUsage.usedTokens)} /{' '} + {formatNumber(contextUsage.tokenLimit)} context used +
+ {contextUsage.model &&
Model: {contextUsage.model}
} +
+ ); + + return ( + + + + ); + }; + return (
@@ -240,6 +320,9 @@ export const InputForm: React.FC = ({ {/* Spacer */}
+ {/* Context usage indicator */} + {renderContextIndicator()} + {/* @yiliang114. closed temporarily */} {/* Thinking button */} {/*