mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-08 01:49:14 +00:00
feat(vscode-ide-companion): support context left
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export type ModeInfo = z.infer<typeof modeInfoSchema>;
|
||||
export type ModesData = z.infer<typeof modesDataSchema>;
|
||||
|
||||
export type AgentInfo = z.infer<typeof agentInfoSchema>;
|
||||
export type ModelInfo = z.infer<typeof modelInfoSchema>;
|
||||
|
||||
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
|
||||
|
||||
@@ -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<typeof currentModeUpdateSchema>;
|
||||
|
||||
export const currentModelUpdateSchema = z.object({
|
||||
sessionUpdate: z.literal('current_model_update'),
|
||||
model: modelInfoSchema,
|
||||
});
|
||||
|
||||
export type CurrentModelUpdate = z.infer<typeof currentModelUpdateSchema>;
|
||||
|
||||
export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
@@ -555,6 +569,7 @@ export const sessionUpdateSchema = z.union([
|
||||
sessionUpdate: z.literal('plan'),
|
||||
}),
|
||||
currentModeUpdateSchema,
|
||||
currentModelUpdateSchema,
|
||||
availableCommandsUpdateSchema,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await this.sendCurrentModelUpdate(force);
|
||||
}
|
||||
|
||||
private async sendCurrentModelUpdate(force: boolean = false): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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<AcpResponse> {
|
||||
// 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,
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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<void> {
|
||||
// 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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<PlanEntry[]>([]);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
|
||||
const [modelInfo] = useState<{
|
||||
name: string;
|
||||
contextLimit?: number | null;
|
||||
} | null>(null);
|
||||
const [usageStats, setUsageStats] = useState<UsageStatsPayload | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
@@ -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)}
|
||||
|
||||
@@ -118,6 +118,20 @@ export class WebViewProvider {
|
||||
});
|
||||
});
|
||||
|
||||
this.agentManager.onUsageUpdate((stats) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'usageStats',
|
||||
data: stats,
|
||||
});
|
||||
});
|
||||
|
||||
this.agentManager.onModelInfo((info) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'modelInfo',
|
||||
data: info,
|
||||
});
|
||||
});
|
||||
|
||||
// Setup end-turn handler from ACP stopReason notifications
|
||||
this.agentManager.onEndTurn((reason) => {
|
||||
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
interface TooltipProps {
|
||||
children: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
children,
|
||||
content,
|
||||
position = 'top',
|
||||
}) => (
|
||||
<div className="relative inline-block">
|
||||
<div className="group relative">
|
||||
{children}
|
||||
<div
|
||||
className={`
|
||||
absolute z-50 px-2 py-1 text-xs rounded-md shadow-lg
|
||||
bg-[var(--app-primary-background)] border border-[var(--app-input-border)]
|
||||
text-[var(--app-primary-foreground)] whitespace-nowrap
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-150
|
||||
-translate-x-1/2 left-1/2
|
||||
${
|
||||
position === 'top'
|
||||
? '-translate-y-1 bottom-full mb-1'
|
||||
: position === 'bottom'
|
||||
? 'translate-y-1 top-full mt-1'
|
||||
: position === 'left'
|
||||
? '-translate-x-full left-0 translate-y-[-50%] top-1/2'
|
||||
: 'translate-x-0 right-0 translate-y-[-50%] top-1/2'
|
||||
}
|
||||
pointer-events-none
|
||||
`}
|
||||
>
|
||||
{content}
|
||||
<div
|
||||
className={`
|
||||
absolute w-2 h-2 bg-[var(--app-primary-background)] border-l border-b border-[var(--app-input-border)]
|
||||
-rotate-45
|
||||
${
|
||||
position === 'top'
|
||||
? 'top-full left-1/2 -translate-x-1/2 -translate-y-1/2'
|
||||
: position === 'bottom'
|
||||
? 'bottom-full left-1/2 -translate-x-1/2 translate-y-1/2'
|
||||
: position === 'left'
|
||||
? 'right-full top-1/2 translate-x-1/2 -translate-y-1/2'
|
||||
: 'left-full top-1/2 -translate-x-1/2 -translate-y-1/2'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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<InputFormProps> = ({
|
||||
activeFileName,
|
||||
activeSelection,
|
||||
skipAutoActiveContext,
|
||||
contextUsage,
|
||||
onInputChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
@@ -143,6 +151,78 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
? `${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 = (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-medium">
|
||||
{percentFormatted}% • {formatNumber(contextUsage.usedTokens)} /{' '}
|
||||
{formatNumber(contextUsage.tokenLimit)} context used
|
||||
</div>
|
||||
{contextUsage.model && <div>Model: {contextUsage.model}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} position="top">
|
||||
<button
|
||||
className="btn-icon-compact"
|
||||
aria-label={`${percentFormatted}% • ${formatNumber(contextUsage.usedTokens)} / ${formatNumber(contextUsage.tokenLimit)} context used`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" role="presentation">
|
||||
<circle
|
||||
className="context-indicator__track"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<circle
|
||||
className="context-indicator__progress"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
style={{
|
||||
transform: 'rotate(-90deg)',
|
||||
transformOrigin: '50% 50%',
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0 bg-gradient-to-b from-transparent to-[var(--app-primary-background)]">
|
||||
<div className="block">
|
||||
@@ -240,6 +320,9 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
{/* Spacer */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{/* Context usage indicator */}
|
||||
{renderContextIndicator()}
|
||||
|
||||
{/* @yiliang114. closed temporarily */}
|
||||
{/* Thinking button */}
|
||||
{/* <button
|
||||
|
||||
@@ -376,6 +376,18 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
|
||||
// Verify agent is connected before sending message
|
||||
if (!this.agentManager.isConnected) {
|
||||
throw new Error('Agent is not connected. Please try again.');
|
||||
}
|
||||
|
||||
// Verify there's an active session
|
||||
if (!this.agentManager.currentSessionId) {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
await this.agentManager.createNewSession(workingDir);
|
||||
}
|
||||
|
||||
await this.agentManager.sendMessage(formattedText);
|
||||
|
||||
// Save assistant message
|
||||
|
||||
@@ -11,7 +11,10 @@ import type {
|
||||
PermissionOption,
|
||||
ToolCall as PermissionToolCall,
|
||||
} from '../components/PermissionDrawer/PermissionRequest.js';
|
||||
import type { ToolCallUpdate } from '../../types/chatTypes.js';
|
||||
import type {
|
||||
ToolCallUpdate,
|
||||
UsageStatsPayload,
|
||||
} from '../../types/chatTypes.js';
|
||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||
import type { PlanEntry } from '../../types/chatTypes.js';
|
||||
|
||||
@@ -119,6 +122,12 @@ interface UseWebViewMessagesProps {
|
||||
setEditMode?: (mode: ApprovalModeValue) => void;
|
||||
// Authentication state setter
|
||||
setIsAuthenticated?: (authenticated: boolean | null) => void;
|
||||
// Usage stats setter
|
||||
setUsageStats?: (stats: UsageStatsPayload | undefined) => void;
|
||||
// Model info setter
|
||||
setModelInfo?: (
|
||||
info: { name: string; contextLimit?: number | null } | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,12 +146,18 @@ export const useWebViewMessages = ({
|
||||
setInputText,
|
||||
setEditMode,
|
||||
setIsAuthenticated,
|
||||
setUsageStats,
|
||||
setModelInfo,
|
||||
}: UseWebViewMessagesProps) => {
|
||||
// VS Code API for posting messages back to the extension host
|
||||
const vscode = useVSCode();
|
||||
// Track active long-running tool calls (execute/bash/command) so we can
|
||||
// keep the bottom "waiting" message visible until all of them complete.
|
||||
const activeExecToolCallsRef = useRef<Set<string>>(new Set());
|
||||
const modelInfoRef = useRef<{
|
||||
name: string;
|
||||
contextLimit?: number | null;
|
||||
} | null>(null);
|
||||
// Use ref to store callbacks to avoid useEffect dependency issues
|
||||
const handlersRef = useRef({
|
||||
sessionManagement,
|
||||
@@ -153,6 +168,8 @@ export const useWebViewMessages = ({
|
||||
setPlanEntries,
|
||||
handlePermissionRequest,
|
||||
setIsAuthenticated,
|
||||
setUsageStats,
|
||||
setModelInfo,
|
||||
});
|
||||
|
||||
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
|
||||
@@ -198,6 +215,8 @@ export const useWebViewMessages = ({
|
||||
setPlanEntries,
|
||||
handlePermissionRequest,
|
||||
setIsAuthenticated,
|
||||
setUsageStats,
|
||||
setModelInfo,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -230,6 +249,41 @@ export const useWebViewMessages = ({
|
||||
break;
|
||||
}
|
||||
|
||||
case 'usageStats': {
|
||||
const stats = message.data as UsageStatsPayload | undefined;
|
||||
if (
|
||||
stats &&
|
||||
(!stats.tokenLimit || stats.tokenLimit <= 0) &&
|
||||
modelInfoRef.current?.contextLimit
|
||||
) {
|
||||
handlers.setUsageStats?.({
|
||||
...stats,
|
||||
tokenLimit: modelInfoRef.current.contextLimit ?? undefined,
|
||||
});
|
||||
} else {
|
||||
handlers.setUsageStats?.(stats);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'modelInfo': {
|
||||
const info = message.data as
|
||||
| { name?: string; contextLimit?: number | null }
|
||||
| undefined;
|
||||
if (info && typeof info.name === 'string') {
|
||||
const normalized = {
|
||||
name: info.name,
|
||||
contextLimit: info.contextLimit,
|
||||
};
|
||||
modelInfoRef.current = normalized;
|
||||
handlers.setModelInfo?.(normalized);
|
||||
} else {
|
||||
modelInfoRef.current = null;
|
||||
handlers.setModelInfo?.(null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'loginSuccess': {
|
||||
// Clear loading state and show a short assistant notice
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
|
||||
@@ -151,6 +151,28 @@
|
||||
fill: var(--app-qwen-ivory);
|
||||
}
|
||||
|
||||
.context-indicator {
|
||||
@apply inline-flex items-center gap-1 px-1 py-0.5 rounded-small text-[0.8em] select-none;
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
.context-indicator svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.context-indicator__track,
|
||||
.context-indicator__progress {
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
.context-indicator__track {
|
||||
stroke: var(--app-secondary-foreground);
|
||||
opacity: 0.35;
|
||||
}
|
||||
.context-indicator__progress {
|
||||
stroke: var(--app-secondary-foreground);
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.composer-overlay {
|
||||
@apply absolute inset-0 rounded-large z-0;
|
||||
background: var(--app-input-background);
|
||||
|
||||
Reference in New Issue
Block a user