feat(vscode-ide-companion): support context left

This commit is contained in:
yiliang114
2025-12-24 01:09:21 +08:00
parent 642dda0315
commit 8e64c5acaf
16 changed files with 507 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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