Skip to content

Commit 293781c

Browse files
author
王璨
committed
feat: add context window usage bar to WebUI header
- Add real-time segmented bar showing token usage by category: System / Rules / User / Thinking / ReadWrite / Edit / Shell / Skill / MCP / Other / Free - Extend ContextManager with getCategoryBreakdown() using raw agent.state.messages (handles system, user, assistant, toolResult roles with OpenAI-format array content) - Add context_window WebSocket event with throttled broadcast (throttled on tool_end, immediate on turn_end/connect/clear) - Classify tools by name prefix (read_file→ReadWrite, edit→Edit, bash→Shell, mcp__*→MCP) and skill list - Distinguish System (1st system msg) vs Rules (subsequent) - Render ContextWindowBar component in header center zone with hover tooltip, 22px bar, consistent k/M units
1 parent dc9c938 commit 293781c

10 files changed

Lines changed: 446 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
## Web 前端
1717

18-
修改 Web UI 前必读 `openspec/specs/web-frontend/spec.md`,禁止引入第三方设计体系。
18+
修改 Web UI 前必读 `openspec/specs/web-frontend/spec.md` 和 taste-skill,禁止引入第三方设计体系。
1919

2020
## 运行
2121

src/context/manager.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ import type { ContextConfig } from "../core/types.js";
22
import { estimateMessagesTokens, estimateTokens } from "./estimator.js";
33
import { dropOldest, slidingWindow } from "./compaction.js";
44

5+
export type UsageCategory =
6+
| "system"
7+
| "rules"
8+
| "user"
9+
| "thinking"
10+
| "readwrite"
11+
| "edit"
12+
| "shell"
13+
| "skill"
14+
| "mcp"
15+
| "other";
16+
517
export class ContextManager {
618
private config: ContextConfig;
719
private contextWindow: number = 128000;
@@ -43,6 +55,127 @@ export class ContextManager {
4355
return this.contextWindow - this.maxTokens - reservedSystem - reservedTools;
4456
}
4557

58+
getContextWindow(): number {
59+
return this.contextWindow;
60+
}
61+
62+
classifyToolCategory(toolName: string, skillNames?: Set<string>): UsageCategory {
63+
if (toolName.startsWith("mcp__")) return "mcp";
64+
if (skillNames?.has(toolName)) return "skill";
65+
if (
66+
toolName === "read_file" ||
67+
toolName === "list_files" ||
68+
toolName === "grep" ||
69+
toolName === "glob" ||
70+
toolName === "write_file" ||
71+
toolName === "overwrite_file"
72+
)
73+
return "readwrite";
74+
if (toolName === "edit" || toolName === "edit_undo") return "edit";
75+
if (toolName === "bash") return "shell";
76+
return "other";
77+
}
78+
79+
getCategoryBreakdown(
80+
messages: unknown[],
81+
tools: { name: string; result: string }[],
82+
skillNames?: Set<string>,
83+
): {
84+
total: number;
85+
used: number;
86+
free: number;
87+
categories: Record<UsageCategory, number>;
88+
} {
89+
const categories: Record<UsageCategory, number> = {
90+
system: 0,
91+
rules: 0,
92+
user: 0,
93+
thinking: 0,
94+
readwrite: 0,
95+
edit: 0,
96+
shell: 0,
97+
skill: 0,
98+
mcp: 0,
99+
other: 0,
100+
};
101+
102+
let systemMsgIndex = 0;
103+
104+
// Classify messages
105+
for (const msg of messages) {
106+
const m = msg as any;
107+
const role = m?.role as string | undefined;
108+
const content = m?.content;
109+
110+
if (role === "system") {
111+
systemMsgIndex++;
112+
const target = systemMsgIndex === 1 ? "system" : "rules";
113+
if (typeof content === "string") {
114+
categories[target] += estimateTokens(content);
115+
categories[target] += 4; // framing
116+
} else if (Array.isArray(content)) {
117+
for (const block of content) {
118+
if (block?.type === "text" && typeof block.text === "string") {
119+
categories[target] += estimateTokens(block.text);
120+
}
121+
}
122+
categories[target] += 4;
123+
}
124+
} else if (role === "user") {
125+
if (typeof content === "string") {
126+
categories.user += estimateTokens(content);
127+
} else if (Array.isArray(content)) {
128+
for (const block of content) {
129+
if (block?.type === "text" && typeof block.text === "string") {
130+
categories.user += estimateTokens(block.text);
131+
}
132+
}
133+
}
134+
categories.user += 4;
135+
} else if (role === "assistant") {
136+
// Raw agent messages use OpenAI format: content is an array of blocks
137+
if (typeof content === "string") {
138+
categories.thinking += estimateTokens(content);
139+
} else if (Array.isArray(content)) {
140+
for (const block of content) {
141+
if (block?.type === "text" && typeof block.text === "string") {
142+
categories.thinking += estimateTokens(block.text);
143+
} else if (block?.type === "thinking" && typeof block.thinking === "string") {
144+
categories.thinking += estimateTokens(block.thinking);
145+
}
146+
}
147+
}
148+
categories.thinking += 4;
149+
} else if (role === "toolResult") {
150+
const toolName = (m?.toolName as string) ?? "";
151+
const cat = this.classifyToolCategory(toolName, skillNames);
152+
if (Array.isArray(content)) {
153+
for (const block of content) {
154+
if (block?.type === "text" && typeof block.text === "string") {
155+
categories[cat] += estimateTokens(block.text);
156+
}
157+
}
158+
} else if (typeof content === "string") {
159+
categories[cat] += estimateTokens(content);
160+
}
161+
categories[cat] += 20; // tool call structure overhead
162+
}
163+
}
164+
165+
// Classify tool calls
166+
for (const tool of tools) {
167+
const cat = this.classifyToolCategory(tool.name, skillNames);
168+
const resultTokens = typeof tool.result === "string" ? estimateTokens(tool.result) : 0;
169+
categories[cat] += resultTokens + 20; // ~20 tokens for tool call structure
170+
}
171+
172+
const used = Object.values(categories).reduce((a, b) => a + b, 0);
173+
const total = this.contextWindow;
174+
const free = Math.max(0, total - used);
175+
176+
return { total, used, free, categories };
177+
}
178+
46179
getEstimatedTokens(messages: unknown[]): number {
47180
return estimateMessagesTokens(messages);
48181
}

src/ui/shared/types.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ export interface McpAppInfo {
4141
resourceUri: string;
4242
}
4343

44+
// ── Context window ──
45+
46+
export interface ContextWindowData {
47+
total: number;
48+
used: number;
49+
free: number;
50+
categories: {
51+
system: number;
52+
rules: number;
53+
user: number;
54+
thinking: number;
55+
readwrite: number;
56+
edit: number;
57+
shell: number;
58+
skill: number;
59+
mcp: number;
60+
other: number;
61+
};
62+
}
63+
4464
// ── Config ──
4565

4666
export interface ConfigData {
@@ -112,7 +132,6 @@ export interface UIMessage {
112132
isStreaming?: boolean;
113133
images?: (ImageAttachment | ImageRef)[];
114134
}
115-
116135
// ── Permissions ──
117136

118137
export type PermissionDecision = "allow" | "deny" | "ask";
@@ -161,6 +180,8 @@ export type ServerEvent =
161180
| { type: "thinking_delta"; delta: string }
162181
| { type: "text_delta"; delta: string }
163182
| { type: "tool_start"; name: string; args: unknown }
183+
| { type: "context_window"; total: number; used: number; free: number; categories: { system: number; rules: number; user: number; thinking: number; readwrite: number; edit: number; shell: number; skill: number; mcp: number; other: number } }
184+
164185
| { type: "tool_end"; name: string; result: string; isError: boolean; images?: ImageAttachment[] }
165186
| { type: "assistant_end" }
166187
| { type: "info"; text: string; display: "toast" | "panel" }

src/ui/web/protocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ export type {
1414
FileListItem,
1515
ConversationMessage,
1616
ToolCallEntry,
17+
ContextWindowData,
1718
} from "../../ui/shared/types.js";

src/ui/web/web-backend.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export class WebUiBackend implements UiBackend {
8888
tools: ToolCallEntry[];
8989
} | null = null;
9090

91+
// Context window broadcast throttling
92+
private contextWindowThrottleTimer: ReturnType<typeof setTimeout> | null = null;
93+
private contextWindowThrottlePending: boolean = false;
94+
private isAssistantTurn: boolean = false;
95+
9196
constructor(options: WebUiOptions) {
9297
this.port = options.port;
9398
this.harness = options.harness;
@@ -128,16 +133,19 @@ export class WebUiBackend implements UiBackend {
128133
this.currentAssistant.tools.push({ name: e.name, args: "", result: rs, isError: e.isError, images: imgs });
129134
}
130135
this.broadcast({ type: "tool_end", name: e.name, result: rs, isError: e.isError, images: imgs });
131-
});
136+
this.broadcastContextWindow(false); });
132137
h.events.on("turn:streaming:start", () => {
133138
this.currentAssistant = { thinking: "", text: "", tools: [] };
134139
this.broadcast({ type: "assistant_start" });
135140
this.startSessionTimeBroadcast();
136-
});
141+
this.isAssistantTurn = true; });
137142
h.events.on("turn:end", () => {
138143
this.broadcastSessionTime();
144+
const toolsForBroadcast = this.currentAssistant?.tools ?? [];
139145
this.currentAssistant = null;
140146
this.broadcast({ type: "assistant_end" });
147+
this.isAssistantTurn = false;
148+
this.broadcastContextWindow(true, toolsForBroadcast);
141149
this.pushSessionListToAll();
142150
});
143151
h.events.on("turn:abort", () => { this.stopSessionTimeBroadcast(); this.broadcast({ type: "loader", state: "hide" }); });
@@ -149,7 +157,7 @@ export class WebUiBackend implements UiBackend {
149157
h.events.on("ui:error", (e) => { this.broadcast({ type: "error", text: e.text }); });
150158
h.events.on("ui:warning", (e) => { this.broadcast({ type: "warning", text: e.text }); });
151159
h.events.on("ui:image:pending", (e) => { this.pendingImages.push(e.image); });
152-
h.events.on("ui:conversation:clear", () => { this.currentAssistant = null; this.pendingImages = []; this.broadcast({ type: "clear_conversation" }); });
160+
h.events.on("ui:conversation:clear", () => { this.currentAssistant = null; this.pendingImages = []; this.broadcast({ type: "clear_conversation" }); this.broadcastContextWindow(true); });
153161
h.events.on("config:change", (e) => { this.broadcast({ type: "config", data: e.data }); });
154162
h.events.on("mcp:state", (e) => { this.broadcast({ type: "mcp_state", servers: e.servers }); });
155163
h.events.on("mcp:browser:open", () => { this.pushMcpState(); this.broadcast({ type: "mcp_open_browser" }); });
@@ -248,12 +256,15 @@ export class WebUiBackend implements UiBackend {
248256
});
249257
}
250258
this.broadcast({ type: "tool_end", name, result: resultStr, isError, images });
259+
this.broadcastContextWindow(false);
251260
}
252261
finishAssistantMessage(): void {
253262
this.broadcastSessionTime();
254263
this.stopSessionTimeBroadcast();
264+
const toolsForBroadcast2 = this.currentAssistant?.tools ?? [];
255265
this.currentAssistant = null;
256266
this.broadcast({ type: "assistant_end" });
267+
this.broadcastContextWindow(true, toolsForBroadcast2);
257268

258269
const sm2 = this.harness.sessionManager;
259270
if (sm2) {
@@ -376,9 +387,11 @@ export class WebUiBackend implements UiBackend {
376387
}
377388

378389
clearConversationView(): void {
390+
const toolsForBroadcast3 = this.currentAssistant?.tools ?? [];
379391
this.currentAssistant = null;
380392
this.pendingImages = [];
381393
this.broadcast({ type: "clear_conversation" });
394+
this.broadcastContextWindow(true, toolsForBroadcast3);
382395
}
383396

384397
// ── UiBackend Processing ──
@@ -435,7 +448,7 @@ export class WebUiBackend implements UiBackend {
435448
config: configData,
436449
messages,
437450
});
438-
451+
this.broadcastContextWindow(true);
439452
if (this.mcpManager) {
440453
this.pushMcpState();
441454
}
@@ -1156,6 +1169,51 @@ export class WebUiBackend implements UiBackend {
11561169
return rebuildDisplayMessages(messages, vms) as any;
11571170
}
11581171

1172+
1173+
private getSkillToolNames(): Set<string> {
1174+
const names = new Set<string>();
1175+
const sm = this.harness.skillManager;
1176+
if (sm) {
1177+
for (const name of sm.listAllSkillNames()) {
1178+
names.add(name);
1179+
}
1180+
}
1181+
return names;
1182+
}
1183+
1184+
private broadcastContextWindow(bypassThrottle: boolean, toolsOverride?: { name: string; result: string }[]): void {
1185+
const cm = this.harness.contextManager;
1186+
const cw = cm.getContextWindow();
1187+
if (cw <= 0) return;
1188+
1189+
if (!bypassThrottle) {
1190+
if (this.contextWindowThrottleTimer) {
1191+
this.contextWindowThrottlePending = true;
1192+
return;
1193+
}
1194+
this.contextWindowThrottleTimer = setTimeout(() => {
1195+
this.contextWindowThrottleTimer = null;
1196+
if (this.contextWindowThrottlePending) {
1197+
this.contextWindowThrottlePending = false;
1198+
this.broadcastContextWindow(true, toolsOverride);
1199+
}
1200+
}, 500);
1201+
}
1202+
1203+
const messages = this.harness.agent.state.messages as unknown[];
1204+
const tools = toolsOverride ?? this.currentAssistant?.tools ?? [];
1205+
const breakdown = cm.getCategoryBreakdown(messages, tools, this.getSkillToolNames());
1206+
1207+
this.broadcast({
1208+
type: "context_window",
1209+
total: breakdown.total,
1210+
used: breakdown.used,
1211+
free: breakdown.free,
1212+
categories: breakdown.categories,
1213+
});
1214+
}
1215+
1216+
11591217
private broadcast(event: ServerEvent): void {
11601218
this.wsServer.broadcast(event);
11611219
}

web/src/components/App.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { useState, useCallback, useRef, useEffect } from "react";
2-
import type { UIMessage, ServerEvent, ConfigData, SessionInfo, McpServerInfo, ImageAttachment, FileListItem } from "../types";
2+
import type { UIMessage, ServerEvent, ConfigData, SessionInfo, McpServerInfo, ImageAttachment, FileListItem, ContextWindowData } from "../types";
33
import { conversationReducer } from "@dscode/shared/reducer";
44
import { useWebSocket } from "../hooks/useWebSocket";
55
import { ChatView } from "./ChatView";
66
import { MessageInput } from "./MessageInput";
77
import { Sidebar } from "./Sidebar";
88
import { ToastContainer, useToasts } from "./Toast";
99
import { CommandPanel } from "./CommandPanel";
10+
import { ContextWindowBar } from "./ContextWindowBar";
1011
import { List, Sun, Moon } from "@phosphor-icons/react";
1112

1213
const SLASH_COMMANDS = [
@@ -53,6 +54,7 @@ export function App() {
5354
const [fileListItems, setFileListItems] = useState<FileListItem[]>([]);
5455
const [fileListPrefix, setFileListPrefix] = useState("");
5556
const [theme, setTheme] = useState<"light" | "dark">(getInitialTheme);
57+
const [contextWindow, setContextWindow] = useState<ContextWindowData | null>(null);
5658
const [commandPanel, setCommandPanel] = useState<string | null>(null);
5759
const { toasts, addToast, removeToast } = useToasts();
5860
const turnStartRef = useRef<number>(0);
@@ -120,6 +122,7 @@ export function App() {
120122
case "mcp_state": setMcpServers(event.servers); break;
121123
case "mcp_open_browser": setSidebarOpen(true); setSidebarTab("mcp"); break;
122124
case "model": setModel(event.name); break;
125+
case "context_window": setContextWindow(event); break;
123126
case "config": setConfig(event.data); break;
124127
case "file_list_result": setFileListItems(event.items); setFileListPrefix(event.prefix); break;
125128
}
@@ -161,14 +164,17 @@ export function App() {
161164

162165
return (
163166
<div className="h-screen flex flex-col" style={{ backgroundColor: "var(--color-bg)" }}>
164-
<header className="flex items-center justify-between px-4 py-2 shrink-0" style={{ backgroundColor: "var(--color-surface)", borderBottom: "1px solid var(--color-border)" }}>
167+
<header className="flex items-center px-4 py-2 shrink-0" style={{ backgroundColor: "var(--color-surface)", borderBottom: "1px solid var(--color-border)" }}>
165168
<div className="flex items-center gap-3">
166169
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2 rounded-btn hover:brightness-95 transition-[filter] duration-200 md:hidden" style={{ backgroundColor: "var(--color-surface-hover)" }} aria-label="Toggle sidebar">
167170
<List size={20} weight="bold" style={{ color: "var(--color-text)" }} />
168171
</button>
169172
<h1 className="font-bold text-lg" style={{ color: "var(--color-accent)" }}>DSCode</h1>
170173
{model && <span className="text-sm hidden sm:inline" style={{ color: "var(--color-text-muted)" }}>{model}</span>}
171174
</div>
175+
<div className="flex-1 flex justify-center hidden md:flex">
176+
<ContextWindowBar data={contextWindow} />
177+
</div>
172178
<div className="flex items-center gap-3">
173179
<button onClick={toggleTheme} className="p-2 rounded-btn hover:brightness-95 transition-[filter] duration-200" style={{ backgroundColor: "var(--color-surface-hover)" }} aria-label="Toggle theme">
174180
{theme === "light" ? <Moon size={18} weight="bold" style={{ color: "var(--color-text)" }} /> : <Sun size={18} weight="bold" style={{ color: "var(--color-text)" }} />}

0 commit comments

Comments
 (0)