Add context window usage indicator

Add a bundled context-window plugin with a composer usage ring, responsive breakdown popover, token counts, percentages, and free-space reporting. Keep accounting plugin-owned, reuse existing history token counts, cache bounded prompt fragments by content, and expose independent mobile and desktop visibility controls.

Alessandro committed Aug 23, 2026 at 06:08 UTC f8c96860c20e589b1edff426019cda247bbd20a9
25 files changed +942 -1
helpers/settings.py
+1
@@ -171,6 +171,7 @@ UI_CONTROL_VISIBILITY_DEFAULTS = {
171 "projectSelector": {"mobile": True, "desktop": True},
172 "time": {"mobile": False, "desktop": True},
173 "connectionStatus": {"mobile": True, "desktop": True},
174 + "contextWindowUsage": {"mobile": True, "desktop": True},
175 "rightCanvasRail": {"mobile": True, "desktop": True},
176 }
177
helpers/settings.py.dox.md
+1 -1
@@ -70,7 +70,7 @@
70 within each `Agent.prepare_prompt()` call.
71 - Explicit reloads also refresh an active prompt snapshot.
72 - `max_consecutive_unusable_responses` defaults to `5` and controls the cost circuit breaker for malformed or repeated main-model outputs.
73 -- `ui_control_visibility` stores validated mobile and desktop visibility flags for the project selector, clock, connection status, and right canvas rail; missing or malformed values fall back per device.
73 +- `ui_control_visibility` stores validated mobile and desktop visibility flags for the project selector, clock, connection status, context-window usage indicator, and right canvas rail; missing or malformed values fall back per device.
74 - The Global default-profile selector lists only globally available profiles.
75 A currently configured unavailable profile remains visible with an explicit
76 unavailable label so settings can round-trip it truthfully, except that the
plugins/AGENTS.md
+1
@@ -75,6 +75,7 @@ Direct child DOX files:
75 | [_chat_compaction/AGENTS.md](_chat_compaction/AGENTS.md) | Full-chat compaction into a summary message. |
76 | [_chat_naming/AGENTS.md](_chat_naming/AGENTS.md) | Built-in manual and Utility Model-assisted chat naming. |
77 | [_commands/AGENTS.md](_commands/AGENTS.md) | Built-in slash command manager, command file discovery, and chat composer slash picker. |
78 +| [_context_window/AGENTS.md](_context_window/AGENTS.md) | Context-window token accounting, API, composer indicator, and visibility control. |
79 | [_code_execution/AGENTS.md](_code_execution/AGENTS.md) | Terminal, Python, and Node.js execution tools and shell runtimes. |
80 | [_desktop/AGENTS.md](_desktop/AGENTS.md) | Linux desktop runtime, sessions, and desktop surface. |
81 | [_discovery/AGENTS.md](_discovery/AGENTS.md) | Welcome-screen plugin discovery cards and promotions. |
plugins/_context_window/AGENTS.md new
+50
@@ -0,0 +1,50 @@
1 +# Context Window Plugin DOX
2 +
3 +## Purpose
4 +
5 +- Own context-window token accounting, the usage API, the composer indicator,
6 + its popover, and its Interface visibility row.
7 +
8 +## Ownership
9 +
10 +- `helpers/usage.py` owns per-prompt bucket measurement and reconciliation.
11 +- `extensions/python/` records prompt parts at their source extension points.
12 +- `api/context_window.py` exposes the active chat's token usage and effective
13 + model limit without returning prompt content.
14 +- `webui/` and `extensions/webui/` own the Alpine store, indicator, popover,
15 + model-override refresh, and Interface visibility row.
16 +
17 +## Local Contracts
18 +
19 +- The six used-token buckets are `messages`, `system_tools`, `skills`,
20 + `mcp_tools`, `system_prompt`, and `extras`.
21 +- Tools, MCP tools, and the available-skills catalog are measured from their
22 + extensible prompt builders, never inferred from rendered headings.
23 +- Loaded skill instructions are removed from Messages and added to Skills.
24 +- Protocol and prompt extras are reported together as Extras.
25 +- Messages reuse the history record token ledger; independently rendered
26 + fragments use a bounded, content-addressed, runtime-only cache.
27 +- Bucket totals reconcile to the already-stored prompt token total; the
28 + unclaimed remainder belongs to System prompt.
29 +- Older chats without a stored breakdown show the explanatory empty state.
30 +- `_model_config` supplies the effective model limit and the
31 + `model-context-strip-end` WebUI slot; it does not own this feature's state.
32 +- The `contextWindowUsage` Interface setting defaults to visible on mobile and
33 + desktop.
34 +
35 +## Work Guidance
36 +
37 +- Keep prompt accounting out of rendered-text heuristics.
38 +- Keep the API response limited to counts needed by the UI.
39 +- Preserve the upward, right-aligned popover geometry used beside the model and
40 + profile selectors.
41 +
42 +## Verification
43 +
44 +- Run `conda run -n a0 pytest plugins/_context_window/tests`.
45 +- Smoke-test the indicator, popover, chat switching, post-run refresh, and
46 + mobile/desktop visibility against the live WebUI.
47 +
48 +## Child DOX Index
49 +
50 +No child DOX files.
plugins/_context_window/README.md new
+9
@@ -0,0 +1,9 @@
1 +# Context Window
2 +
3 +The bundled Context Window plugin adds a compact usage ring beside the chat's
4 +model and agent selectors. Its popover shows token counts and full-window
5 +percentages for Messages, System tools, Skills, MCP tools, System prompt,
6 +Extras, and Free space.
7 +
8 +Older chats gain the detailed breakdown after their next model turn. Mobile and
9 +desktop visibility can be changed under **Settings > Interface**.
plugins/_context_window/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_context_window/api/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_context_window/api/context_window.py new
+18
@@ -0,0 +1,18 @@
1 +from helpers.api import ApiHandler, Input, Output, Request
2 +from plugins._context_window.helpers.usage import usage_snapshot
3 +from plugins._model_config.helpers.model_config import get_chat_model_config
4 +
5 +
6 +class ContextWindow(ApiHandler):
7 + async def process(self, input: Input, request: Request) -> Output:
8 + context = self.use_context(str(input.get("context") or ""))
9 + agent = context.streaming_agent or context.agent0
10 + window = agent.get_data(agent.DATA_NAME_CTX_WINDOW)
11 + window = window if isinstance(window, dict) else {}
12 + config = get_chat_model_config(agent)
13 +
14 + return {
15 + "tokens": max(int(window.get("tokens") or 0), 0),
16 + "context_window": max(int(config.get("ctx_length") or 0), 0),
17 + "usage": usage_snapshot(window.get("usage")),
18 + }
plugins/_context_window/extensions/python/_functions/_11_tools_prompt/build_prompt/end/_90_record_context_usage.py new
+7
@@ -0,0 +1,7 @@
1 +from helpers.extension import Extension
2 +from plugins._context_window.helpers.usage import record_prompt
3 +
4 +
5 +class RecordSystemToolsUsage(Extension):
6 + def execute(self, data: dict | None = None, **kwargs):
7 + record_prompt(self.agent, "system_tools", (data or {}).get("result"))
plugins/_context_window/extensions/python/_functions/_12_mcp_prompt/build_prompt/end/_90_record_context_usage.py new
+7
@@ -0,0 +1,7 @@
1 +from helpers.extension import Extension
2 +from plugins._context_window.helpers.usage import record_prompt
3 +
4 +
5 +class RecordMcpToolsUsage(Extension):
6 + def execute(self, data: dict | None = None, **kwargs):
7 + record_prompt(self.agent, "mcp_tools", (data or {}).get("result"))
plugins/_context_window/extensions/python/_functions/_13_skills_prompt/build_prompt/end/_90_record_context_usage.py new
+7
@@ -0,0 +1,7 @@
1 +from helpers.extension import Extension
2 +from plugins._context_window.helpers.usage import record_prompt
3 +
4 +
5 +class RecordSkillsUsage(Extension):
6 + def execute(self, data: dict | None = None, **kwargs):
7 + record_prompt(self.agent, "skills", (data or {}).get("result"))
plugins/_context_window/extensions/python/_functions/agent/Agent/prepare_prompt/end/_90_store_context_usage.py new
+11
@@ -0,0 +1,11 @@
1 +from helpers.extension import Extension
2 +from plugins._context_window.helpers import usage
3 +
4 +
5 +class StoreContextUsage(Extension):
6 + def execute(self, data: dict | None = None, **kwargs):
7 + payload = data if isinstance(data, dict) else {}
8 + if payload.get("exception") or not isinstance(payload.get("result"), list):
9 + usage.discard(self.agent)
10 + return
11 + usage.finalize(self.agent)
plugins/_context_window/extensions/python/_functions/agent/Agent/prepare_prompt/start/_10_reset_context_usage.py new
+7
@@ -0,0 +1,7 @@
1 +from helpers.extension import Extension
2 +from plugins._context_window.helpers import usage
3 +
4 +
5 +class ResetContextUsage(Extension):
6 + def execute(self, **kwargs):
7 + usage.reset(self.agent)
plugins/_context_window/extensions/python/message_loop_prompts_after/_99_capture_context_usage.py new
+8
@@ -0,0 +1,8 @@
1 +from agent import LoopData
2 +from helpers.extension import Extension
3 +from plugins._context_window.helpers.usage import capture_context
4 +
5 +
6 +class CaptureContextUsage(Extension):
7 + def execute(self, loop_data: LoopData | None = None, **kwargs):
8 + capture_context(self.agent, loop_data)
plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js new
+24
@@ -0,0 +1,24 @@
1 +import { store as contextWindowStore } from "/plugins/_context_window/webui/context-window-store.js";
2 +
3 +const OVERRIDE_REVISION_KEY = "_model_config_override_revision";
4 +let lastContextId = "";
5 +let lastRevision = null;
6 +
7 +export default async function refreshContextWindow(ctx) {
8 + const snapshot = ctx?.snapshot;
9 + const contextId = String(snapshot?.context || "");
10 + if (!contextId) {
11 + lastContextId = "";
12 + lastRevision = null;
13 + return;
14 + }
15 +
16 + const contexts = Array.isArray(snapshot?.contexts) ? snapshot.contexts : [];
17 + const active = contexts.find(item => item?.id === contextId) || null;
18 + const revision = active?.[OVERRIDE_REVISION_KEY] || null;
19 + if (contextId === lastContextId && revision === lastRevision) return;
20 +
21 + lastContextId = contextId;
22 + lastRevision = revision;
23 + await contextWindowStore.refresh(contextId);
24 +}
plugins/_context_window/extensions/webui/interface-controls-end/context-window.html new
+40
@@ -0,0 +1,40 @@
1 +<script type="module">
2 + import { store } from "/plugins/_context_window/webui/context-window-store.js";
3 +</script>
4 +
5 +<div x-data>
6 + <template x-if="$store.settings.uiVisibility">
7 + <div class="ui-visibility-row">
8 + <div class="ui-visibility-control">
9 + <x-icon aria-hidden="true" name="donut_large"></x-icon>
10 + <div>
11 + <div class="field-title">Context window</div>
12 + <div class="ui-visibility-state" x-text="$store.settings.uiControlVisibilityLabel('contextWindowUsage')"></div>
13 + </div>
14 + </div>
15 +
16 + <div class="ui-device-selector" aria-label="Context window visibility">
17 + <button type="button"
18 + class="ui-device-button"
19 + :class="{ 'is-visible': $store.settings.isUiControlVisible('contextWindowUsage', 'mobile') }"
20 + aria-label="Show context window on mobile"
21 + :aria-pressed="$store.settings.isUiControlVisible('contextWindowUsage', 'mobile').toString()"
22 + :title="$store.settings.isUiControlVisible('contextWindowUsage', 'mobile') ? 'Shown on mobile' : 'Hidden on mobile'"
23 + @click="$store.settings.toggleUiControl('contextWindowUsage', 'mobile')">
24 + <x-icon aria-hidden="true" name="smartphone"></x-icon>
25 + <span>Mobile</span>
26 + </button>
27 + <button type="button"
28 + class="ui-device-button"
29 + :class="{ 'is-visible': $store.settings.isUiControlVisible('contextWindowUsage', 'desktop') }"
30 + aria-label="Show context window on desktop"
31 + :aria-pressed="$store.settings.isUiControlVisible('contextWindowUsage', 'desktop').toString()"
32 + :title="$store.settings.isUiControlVisible('contextWindowUsage', 'desktop') ? 'Shown on desktop' : 'Hidden on desktop'"
33 + @click="$store.settings.toggleUiControl('contextWindowUsage', 'desktop')">
34 + <x-icon aria-hidden="true" name="desktop_windows"></x-icon>
35 + <span>Desktop</span>
36 + </button>
37 + </div>
38 + </div>
39 + </template>
40 +</div>
plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html new
+191
@@ -0,0 +1,191 @@
1 +<script type="module">
2 + import { store } from "/plugins/_context_window/webui/context-window-store.js";
3 +</script>
4 +
5 +<div x-data>
6 + <template x-if="$store.contextWindow && $store.preferences.isUiControlVisible('contextWindowUsage')">
7 + <div class="context-window-anchor"
8 + x-create="$store.contextWindow.onMount($watch)"
9 + x-destroy="$store.contextWindow.cleanup()"
10 + @click.outside="$store.contextWindow.open = false"
11 + @keydown.escape.window="$store.contextWindow.open = false">
12 + <button type="button"
13 + class="context-window-button"
14 + :aria-expanded="$store.contextWindow.open"
15 + :aria-label="$store.contextWindow.usage.ariaLabel"
16 + @click="$store.contextWindow.toggle()">
17 + <svg viewBox="0 0 36 36" aria-hidden="true">
18 + <circle class="context-window-ring-track" cx="18" cy="18" r="15.5"></circle>
19 + <circle class="context-window-ring-value" cx="18" cy="18" r="15.5" pathLength="100"
20 + :stroke-dasharray="$store.contextWindow.usage.ringDasharray"></circle>
21 + </svg>
22 + <span x-text="$store.contextWindow.usage.ringLabel"></span>
23 + </button>
24 +
25 + <div class="context-window-popover"
26 + x-show="$store.contextWindow.open"
27 + x-transition.opacity
28 + style="display: none;"
29 + role="dialog"
30 + aria-label="Context window usage">
31 + <div class="context-window-header">
32 + <strong>Context window</strong>
33 + <span x-text="$store.contextWindow.usage.summary"></span>
34 + </div>
35 + <div class="context-window-meter" aria-hidden="true">
36 + <span :style="$store.contextWindow.usage.meterStyle"></span>
37 + </div>
38 + <template x-if="$store.contextWindow.usage.hasBreakdown">
39 + <div class="context-window-rows">
40 + <template x-for="row in $store.contextWindow.usage.rows" :key="row.key">
41 + <div class="context-window-row">
42 + <span class="context-window-dot" :style="row.dotStyle"></span>
43 + <span x-text="row.label"></span>
44 + <span class="context-window-value context-window-tokens" x-text="row.tokensLabel"></span>
45 + <span class="context-window-value" x-text="row.percentLabel"></span>
46 + </div>
47 + </template>
48 + </div>
49 + </template>
50 + <template x-if="$store.contextWindow.usage.missingBreakdown">
51 + <div class="context-window-empty">Breakdown available after the next message.</div>
52 + </template>
53 + </div>
54 + </div>
55 + </template>
56 +</div>
57 +
58 +<style>
59 + .context-window-anchor {
60 + position: static;
61 + display: flex;
62 + align-items: center;
63 + }
64 + .context-window-button {
65 + position: relative;
66 + display: grid;
67 + place-items: center;
68 + width: 1.8rem;
69 + height: 1.8rem;
70 + padding: 0;
71 + border: 0;
72 + border-radius: 50%;
73 + background: transparent;
74 + color: var(--color-text);
75 + cursor: pointer;
76 + opacity: .82;
77 + }
78 + .context-window-button:hover {
79 + background: var(--color-background-hover);
80 + opacity: 1;
81 + }
82 + .context-window-button svg {
83 + position: absolute;
84 + inset: 2px;
85 + width: calc(100% - 4px);
86 + height: calc(100% - 4px);
87 + transform: rotate(-90deg);
88 + fill: none;
89 + stroke-width: 3;
90 + }
91 + .context-window-ring-track {
92 + stroke: color-mix(in srgb, var(--color-text) 18%, transparent);
93 + }
94 + .context-window-ring-value {
95 + stroke: var(--color-highlight);
96 + stroke-linecap: round;
97 + transition: stroke-dasharray .18s ease;
98 + }
99 + .context-window-button > span {
100 + position: relative;
101 + z-index: 1;
102 + font-family: var(--font-family-code);
103 + font-size: .42rem;
104 + font-weight: 600;
105 + letter-spacing: -.03em;
106 + }
107 + .context-window-popover {
108 + position: absolute;
109 + right: 0;
110 + bottom: calc(100% + 6px);
111 + z-index: 1050;
112 + width: min(19rem, calc(100vw - 2rem));
113 + padding: .9rem;
114 + border: 1px solid var(--color-border);
115 + border-radius: 12px;
116 + background-color: color-mix(in srgb, var(--color-panel) 90%, black 10%);
117 + box-shadow: 0 16px 38px rgba(0, 0, 0, .3);
118 + }
119 + .context-window-header,
120 + .context-window-row {
121 + display: grid;
122 + align-items: center;
123 + }
124 + .context-window-header {
125 + grid-template-columns: minmax(0, 1fr) auto;
126 + gap: .75rem;
127 + font-size: .86rem;
128 + }
129 + .context-window-header > span,
130 + .context-window-value {
131 + font-family: var(--font-family-code);
132 + font-variant-numeric: tabular-nums;
133 + }
134 + .context-window-header > span {
135 + color: color-mix(in srgb, var(--color-text) 72%, transparent);
136 + font-size: .76rem;
137 + }
138 + .context-window-meter {
139 + height: .45rem;
140 + margin: .85rem 0 .75rem;
141 + overflow: hidden;
142 + border-radius: 999px;
143 + background: color-mix(in srgb, var(--color-text) 9%, transparent);
144 + }
145 + .context-window-meter > span {
146 + display: block;
147 + height: 100%;
148 + border-radius: inherit;
149 + background: var(--color-highlight);
150 + transition: width .18s ease;
151 + }
152 + .context-window-rows {
153 + display: grid;
154 + gap: .3rem;
155 + }
156 + .context-window-empty {
157 + padding: .35rem 0 .15rem;
158 + color: color-mix(in srgb, var(--color-text) 62%, transparent);
159 + font-size: .8rem;
160 + }
161 + .context-window-row {
162 + grid-template-columns: .55rem minmax(0, 1fr) auto 3rem;
163 + gap: .5rem;
164 + min-height: 1.5rem;
165 + color: color-mix(in srgb, var(--color-text) 78%, transparent);
166 + font-size: .8rem;
167 + }
168 + .context-window-value {
169 + color: var(--color-text);
170 + text-align: right;
171 + }
172 + .context-window-tokens {
173 + color: color-mix(in srgb, var(--color-text) 62%, transparent);
174 + }
175 + .context-window-dot {
176 + width: .55rem;
177 + height: .55rem;
178 + border-radius: 50%;
179 + background: var(--color-highlight);
180 + }
181 + @media (max-width: 25rem) {
182 + .context-window-popover {
183 + right: 1.25rem;
184 + width: min(17rem, calc(100vw - 3rem));
185 + }
186 + }
187 + .light-mode .context-window-popover {
188 + background: var(--color-panel);
189 + box-shadow: 0 4px 14px rgba(0, 0, 0, .12);
190 + }
191 +</style>
plugins/_context_window/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_context_window/helpers/usage.py new
+169
@@ -0,0 +1,169 @@
1 +import hashlib
2 +from typing import Any
3 +
4 +from helpers import files, history, skills, tokens
5 +
6 +
7 +PARTS_KEY = "context_window_usage"
8 +CACHE_KEY = "_context_window_usage_cache"
9 +USAGE_KEYS = (
10 + "messages",
11 + "system_tools",
12 + "skills",
13 + "mcp_tools",
14 + "system_prompt",
15 + "extras",
16 +)
17 +MEASURED_KEYS = tuple(key for key in USAGE_KEYS if key != "system_prompt")
18 +
19 +
20 +def reset(agent: Any) -> None:
21 + params = _temporary_params(agent)
22 + if params is not None:
23 + params[PARTS_KEY] = {}
24 +
25 +
26 +def discard(agent: Any) -> None:
27 + params = _temporary_params(agent)
28 + if params is not None:
29 + params.pop(PARTS_KEY, None)
30 +
31 +
32 +def record_prompt(agent: Any, key: str, prompt: Any) -> None:
33 + parts = _parts(agent)
34 + if parts is None or key not in MEASURED_KEYS:
35 + return
36 + text = files.remove_code_fences(str(prompt or ""), language="json")
37 + parts[key] = _cached_tokens(agent, f"prompt:{key}", text)
38 +
39 +
40 +def capture_context(agent: Any, loop_data: Any) -> None:
41 + parts = _parts(agent)
42 + if parts is None or loop_data is None:
43 + return
44 +
45 + output = list(getattr(loop_data, "history_output", None) or [])
46 + skill_output = [message for message in output if skills.skill_instruction_name(message)]
47 + skill_tokens = _output_tokens(agent, "history_skills", skill_output)
48 + parts["messages"] = max(_history_tokens(agent, output) - skill_tokens, 0)
49 + parts["skills"] = parts.get("skills", 0) + skill_tokens
50 +
51 + protocol_values = {
52 + **getattr(loop_data, "protocol_persistent", {}),
53 + **getattr(loop_data, "protocol_temporary", {}),
54 + }
55 + extras_values = {
56 + **getattr(loop_data, "extras_persistent", {}),
57 + **getattr(loop_data, "extras_temporary", {}),
58 + }
59 + protocol = agent._build_context_message(
60 + "agent.context.protocol.md",
61 + "protocol",
62 + protocol_values,
63 + include_empty=False,
64 + )
65 + extras = agent._build_context_message(
66 + "agent.context.extras.md",
67 + "extras",
68 + extras_values,
69 + include_empty=True,
70 + )
71 + parts["extras"] = _output_tokens(agent, "extras", protocol + extras)
72 +
73 +
74 +def finalize(agent: Any) -> None:
75 + params = _temporary_params(agent)
76 + parts = params.pop(PARTS_KEY, None) if params is not None else None
77 + window = agent.get_data(agent.DATA_NAME_CTX_WINDOW) if agent else None
78 + if not isinstance(parts, dict) or not isinstance(window, dict):
79 + return
80 +
81 + total = _non_negative_int(window.get("tokens"))
82 + usage = {key: _non_negative_int(parts.get(key)) for key in MEASURED_KEYS}
83 + measured_total = sum(usage.values())
84 + if measured_total > total and measured_total:
85 + usage = _scale_to_total(usage, total, measured_total)
86 + measured_total = total
87 + usage["system_prompt"] = total - measured_total
88 + usage = {key: usage.get(key, 0) for key in USAGE_KEYS}
89 +
90 + updated = dict(window)
91 + updated["usage"] = usage
92 + agent.set_data(agent.DATA_NAME_CTX_WINDOW, updated)
93 +
94 +
95 +def usage_snapshot(value: Any) -> dict[str, int]:
96 + if not isinstance(value, dict):
97 + return {}
98 + return {key: _non_negative_int(value.get(key)) for key in USAGE_KEYS}
99 +
100 +
101 +def _parts(agent: Any) -> dict[str, int] | None:
102 + params = _temporary_params(agent)
103 + value = params.get(PARTS_KEY) if params is not None else None
104 + return value if isinstance(value, dict) else None
105 +
106 +
107 +def _temporary_params(agent: Any) -> dict[str, Any] | None:
108 + loop_data = getattr(agent, "loop_data", None)
109 + params = getattr(loop_data, "params_temporary", None)
110 + return params if isinstance(params, dict) else None
111 +
112 +
113 +def _history_tokens(agent: Any, output: list[history.OutputMessage]) -> int:
114 + get_tokens = getattr(getattr(agent, "history", None), "get_tokens", None)
115 + if callable(get_tokens):
116 + return _non_negative_int(get_tokens())
117 + return _count_output_tokens(output)
118 +
119 +
120 +def _output_tokens(
121 + agent: Any, cache_key: str, output: list[history.OutputMessage]
122 +) -> int:
123 + text = history.output_text(output, ai_label="assistant", human_label="user")
124 + return _cached_tokens(agent, cache_key, text)
125 +
126 +
127 +def _count_output_tokens(output: list[history.OutputMessage]) -> int:
128 + text = history.output_text(output, ai_label="assistant", human_label="user")
129 + return tokens.approximate_prompt_tokens(text)
130 +
131 +
132 +def _cached_tokens(agent: Any, key: str, text: str) -> int:
133 + cache = _cache(agent)
134 + digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
135 + cached = cache.get(key) if cache is not None else None
136 + if isinstance(cached, tuple) and len(cached) == 2 and cached[0] == digest:
137 + return _non_negative_int(cached[1])
138 +
139 + count = tokens.approximate_prompt_tokens(text)
140 + if cache is not None:
141 + cache[key] = (digest, count)
142 + return count
143 +
144 +
145 +def _cache(agent: Any) -> dict[str, tuple[str, int]] | None:
146 + data = getattr(agent, "data", None)
147 + if not isinstance(data, dict):
148 + return None
149 + cache = data.get(CACHE_KEY)
150 + if not isinstance(cache, dict):
151 + cache = {}
152 + data[CACHE_KEY] = cache
153 + return cache
154 +
155 +
156 +def _non_negative_int(value: Any) -> int:
157 + try:
158 + return max(int(value or 0), 0)
159 + except (TypeError, ValueError):
160 + return 0
161 +
162 +
163 +def _scale_to_total(values: dict[str, int], total: int, current: int) -> dict[str, int]:
164 + scaled = {key: value * total // current for key, value in values.items()}
165 + remainder = total - sum(scaled.values())
166 + order = sorted(values, key=lambda key: values[key] * total % current, reverse=True)
167 + for key in order[:remainder]:
168 + scaled[key] += 1
169 + return scaled
plugins/_context_window/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: _context_window
2 +title: Context Window
3 +description: Shows token usage for the active chat context window.
4 +version: 0.1.0
5 +settings_sections: []
6 +per_project_config: false
7 +per_agent_config: false
8 +always_enabled: true
plugins/_context_window/tests/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_context_window/tests/test_context_window.py new
+250
@@ -0,0 +1,250 @@
1 +from pathlib import Path
2 +from types import SimpleNamespace
3 +
4 +import pytest
5 +
6 +from agent import Agent, LoopData
7 +from helpers import extension, history
8 +from plugins._context_window.api.context_window import ContextWindow
9 +from plugins._context_window.helpers import usage
10 +
11 +
12 +ROOT = Path(__file__).resolve().parents[3]
13 +
14 +
15 +class _Log:
16 + def set_progress(self, _message: str) -> None:
17 + pass
18 +
19 +
20 +@pytest.mark.asyncio
21 +async def test_usage_follows_prompt_sources_and_reconciles_to_total(monkeypatch):
22 + agent = object.__new__(Agent)
23 + loop_data = LoopData()
24 + agent.loop_data = loop_data
25 + agent.context = SimpleNamespace(log=_Log())
26 + agent.history = history.History(agent)
27 + agent.data = {}
28 + agent.history.add_message(False, "User asks a question.")
29 + agent.history.add_message(True, "Assistant answers.")
30 + agent.history.add_message(
31 + False,
32 + {
33 + "tool_name": "skills_tool",
34 + "tool_result": "Skill instructions without a special heading.",
35 + "skill_instructions": {
36 + "name": "test-skill",
37 + "content_included": True,
38 + },
39 + },
40 + )
41 +
42 + system_parts = {
43 + "system_prompt": "Main instructions without a special heading.",
44 + "system_tools": "Tool definitions without a special heading.",
45 + "mcp_tools": "Remote definitions without a special heading.",
46 + "skills": "Available skill names without a special heading.",
47 + }
48 +
49 + async def get_system_prompt(_loop_data):
50 + for key in ("system_tools", "mcp_tools", "skills"):
51 + usage.record_prompt(agent, key, system_parts[key])
52 + return list(system_parts.values())
53 +
54 + def read_prompt(prompt_file: str, **kwargs) -> str:
55 + if prompt_file == "agent.context.protocol.md":
56 + return "[PROTOCOL]\n" + kwargs["protocol"]
57 + if prompt_file == "agent.context.extras.md":
58 + return "[EXTRAS]\n" + kwargs["extras"]
59 + raise AssertionError(f"Unexpected prompt: {prompt_file}")
60 +
61 + async def call_extensions(extension_point: str, agent=None, **kwargs):
62 + if extension_point == "message_loop_prompts_after":
63 + current = kwargs["loop_data"]
64 + current.protocol_persistent["project"] = "Project instructions."
65 + current.extras_temporary["time"] = "Current time."
66 + usage.capture_context(agent, current)
67 +
68 + agent.get_system_prompt = get_system_prompt
69 + agent.read_prompt = read_prompt
70 + monkeypatch.setattr(extension, "call_extensions_async", call_extensions)
71 + monkeypatch.setattr(history.History, "_get_max_embeds", lambda self: 0)
72 +
73 + usage.reset(agent)
74 + await Agent.prepare_prompt.__wrapped__(agent, loop_data)
75 + usage.finalize(agent)
76 +
77 + window = agent.get_data(Agent.DATA_NAME_CTX_WINDOW)
78 + breakdown = window["usage"]
79 + assert tuple(breakdown) == usage.USAGE_KEYS
80 + assert sum(breakdown.values()) == window["tokens"]
81 + assert all(breakdown[key] > 0 for key in usage.USAGE_KEYS)
82 + assert usage.PARTS_KEY not in loop_data.params_temporary
83 +
84 +
85 +@pytest.mark.asyncio
86 +async def test_api_returns_only_counts_and_effective_limit(monkeypatch):
87 + agent = SimpleNamespace(
88 + DATA_NAME_CTX_WINDOW="ctx_window",
89 + get_data=lambda _key: {
90 + "text": "private prompt",
91 + "tokens": 120,
92 + "usage": {"messages": 42},
93 + },
94 + )
95 + handler = object.__new__(ContextWindow)
96 + handler.use_context = lambda _context_id: SimpleNamespace(
97 + streaming_agent=None,
98 + agent0=agent,
99 + )
100 + monkeypatch.setattr(
101 + "plugins._context_window.api.context_window.get_chat_model_config",
102 + lambda _agent: {"ctx_length": 128_000},
103 + )
104 +
105 + result = await handler.process({"context": "ctx-1"}, SimpleNamespace())
106 +
107 + assert result == {
108 + "tokens": 120,
109 + "context_window": 128_000,
110 + "usage": {
111 + "messages": 42,
112 + "system_tools": 0,
113 + "skills": 0,
114 + "mcp_tools": 0,
115 + "system_prompt": 0,
116 + "extras": 0,
117 + },
118 + }
119 + assert "text" not in result
120 +
121 +
122 +def test_webui_and_accounting_are_plugin_owned():
123 + model_switcher = (
124 + ROOT
125 + / "plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html"
126 + ).read_text(encoding="utf-8")
127 + model_store = (ROOT / "plugins/_model_config/webui/switcher-mixin.js").read_text(
128 + encoding="utf-8"
129 + )
130 + component = (
131 + ROOT
132 + / "plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html"
133 + ).read_text(encoding="utf-8")
134 + context_store = (
135 + ROOT / "plugins/_context_window/webui/context-window-store.js"
136 + ).read_text(encoding="utf-8")
137 + helper = (ROOT / "plugins/_context_window/helpers/usage.py").read_text(
138 + encoding="utf-8"
139 + )
140 +
141 + assert 'id="model-context-strip-end"' in model_switcher
142 + assert "contextWindowUsage" not in model_switcher
143 + assert "contextUsage" not in model_store
144 + assert "Context window" in component
145 + assert "position: static" in component
146 + assert "width: min(19rem, calc(100vw - 2rem))" in component
147 + assert "right: 1.25rem" in component
148 + assert "width: min(17rem, calc(100vw - 3rem))" in component
149 + assert 'label: "Free space"' in context_store
150 + assert "Breakdown available after the next message." in component
151 + assert "startswith(" not in helper
152 + assert "rpartition(" not in helper
153 +
154 +
155 +def test_source_prompt_extensions_are_registered():
156 + expected = {
157 + "_functions/agent/Agent/prepare_prompt/start": "ResetContextUsage",
158 + "_functions/agent/Agent/prepare_prompt/end": "StoreContextUsage",
159 + "message_loop_prompts_after": "CaptureContextUsage",
160 + }
161 + for point, class_name in expected.items():
162 + classes = extension._get_extension_classes(point) # type: ignore[attr-defined]
163 + assert any(cls.__name__ == class_name for cls in classes)
164 +
165 + system_prompt_classes = {
166 + cls.__name__: cls
167 + for cls in extension._get_extension_classes("system_prompt") # type: ignore[attr-defined]
168 + }
169 + for owner, recorder in {
170 + "ToolsPrompt": "RecordSystemToolsUsage",
171 + "MCPToolsPrompt": "RecordMcpToolsUsage",
172 + "SkillsPrompt": "RecordSkillsUsage",
173 + }.items():
174 + builder = system_prompt_classes[owner].execute.__globals__["build_prompt"]
175 + module = builder.__wrapped__.__module__.replace(".", "/")
176 + point = f"_functions/{module}/build_prompt/end"
177 + classes = extension._get_extension_classes(point) # type: ignore[attr-defined]
178 + assert any(cls.__name__ == recorder for cls in classes)
179 +
180 +
181 +def test_prompt_fragment_cache_is_bounded_and_content_addressed(monkeypatch):
182 + calls = []
183 + agent = SimpleNamespace(data={}, loop_data=LoopData())
184 + monkeypatch.setattr(
185 + usage.tokens,
186 + "approximate_prompt_tokens",
187 + lambda text: calls.append(text) or len(text),
188 + )
189 +
190 + usage.reset(agent)
191 + usage.record_prompt(agent, "system_tools", "same prompt")
192 + usage.record_prompt(agent, "system_tools", "same prompt")
193 + usage.record_prompt(agent, "system_tools", "changed prompt")
194 +
195 + assert calls == ["same prompt", "changed prompt"]
196 + cache = agent.data[usage.CACHE_KEY]
197 + assert len(cache) == 1
198 + assert cache["prompt:system_tools"][1] == len("changed prompt")
199 + assert all(len(value[0]) == 64 for value in cache.values())
200 +
201 +
202 +def test_history_ledger_changes_without_invalidating_fragment_cache(monkeypatch):
203 + calls = []
204 + history_tokens = 1_000
205 + agent = SimpleNamespace(
206 + data={},
207 + loop_data=LoopData(),
208 + history=SimpleNamespace(get_tokens=lambda: history_tokens),
209 + _build_context_message=lambda *args, **kwargs: [],
210 + )
211 + skill_message = {
212 + "ai": False,
213 + "content": {
214 + "tool_name": "skills_tool",
215 + "tool_result": "Loaded skill body.",
216 + "skill_instructions": {
217 + "name": "test-skill",
218 + "content_included": True,
219 + },
220 + },
221 + }
222 + loop_data = SimpleNamespace(
223 + history_output=[skill_message],
224 + protocol_persistent={},
225 + protocol_temporary={},
226 + extras_persistent={},
227 + extras_temporary={},
228 + )
229 + monkeypatch.setattr(
230 + usage.tokens,
231 + "approximate_prompt_tokens",
232 + lambda text: calls.append(text) or len(text),
233 + )
234 +
235 + usage.reset(agent)
236 + usage.record_prompt(agent, "system_tools", "stable tools")
237 + usage.capture_context(agent, loop_data)
238 + first = dict(agent.loop_data.params_temporary[usage.PARTS_KEY])
239 +
240 + history_tokens = 400
241 + agent.loop_data.params_temporary = {}
242 + usage.reset(agent)
243 + usage.record_prompt(agent, "system_tools", "stable tools")
244 + usage.capture_context(agent, loop_data)
245 + second = agent.loop_data.params_temporary[usage.PARTS_KEY]
246 +
247 + assert first["messages"] == 1_000 - first["skills"]
248 + assert second["messages"] == 400 - second["skills"]
249 + assert calls.count("stable tools") == 1
250 + assert len(agent.data[usage.CACHE_KEY]) == 3
plugins/_context_window/webui/context-window-store.js new
+117
@@ -0,0 +1,117 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4 +import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
5 +
6 +const API_PATH = "/plugins/_context_window/context_window";
7 +const ROWS = [
8 + { key: "messages", label: "Messages", opacity: 1 },
9 + { key: "system_tools", label: "System tools", opacity: 0.88 },
10 + { key: "skills", label: "Skills", opacity: 0.76 },
11 + { key: "mcp_tools", label: "MCP tools", opacity: 0.64 },
12 + { key: "system_prompt", label: "System prompt", opacity: 0.52 },
13 + { key: "extras", label: "Extras", opacity: 0.4 },
14 +];
15 +
16 +preferencesStore.registerUiControlVisibility("contextWindowUsage", {
17 + mobile: true,
18 + desktop: true,
19 +});
20 +
21 +function formatTokens(value) {
22 + const amount = Math.max(Number(value) || 0, 0);
23 + for (const [size, suffix] of [[1_000_000, "M"], [1_000, "K"]]) {
24 + if (amount >= size) return `${(amount / size).toFixed(1).replace(/\.0$/, "")}${suffix}`;
25 + }
26 + return String(Math.round(amount));
27 +}
28 +
29 +function formatPercent(value) {
30 + const rounded = Math.round(Math.max(Number(value) || 0, 0) * 10) / 10;
31 + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}%`;
32 +}
33 +
34 +function buildUsage(data = {}) {
35 + const tokens = Math.max(Number(data.tokens) || 0, 0);
36 + const contextWindow = Math.max(Number(data.context_window) || 0, 0);
37 + const breakdown = data.usage && typeof data.usage === "object" ? data.usage : {};
38 + const percent = contextWindow > 0 ? (tokens / contextWindow) * 100 : 0;
39 + const rows = ROWS.map(row => {
40 + const rowTokens = Math.max(Number(breakdown[row.key]) || 0, 0);
41 + const rowPercent = contextWindow > 0 ? (rowTokens / contextWindow) * 100 : 0;
42 + return {
43 + ...row,
44 + tokensLabel: formatTokens(rowTokens),
45 + percentLabel: formatPercent(rowPercent),
46 + dotStyle: `opacity:${row.opacity}`,
47 + };
48 + });
49 + const hasBreakdown = rows.some(row => Number(breakdown[row.key]) > 0);
50 + if (hasBreakdown) {
51 + const freeTokens = Math.max(contextWindow - tokens, 0);
52 + const freePercent = contextWindow > 0 ? (freeTokens / contextWindow) * 100 : 0;
53 + rows.push({
54 + key: "free_space",
55 + label: "Free space",
56 + tokensLabel: formatTokens(freeTokens),
57 + percentLabel: formatPercent(freePercent),
58 + dotStyle: "opacity:0.24",
59 + });
60 + }
61 + const percentLabel = formatPercent(percent);
62 + return {
63 + rows: hasBreakdown ? rows : [],
64 + hasBreakdown,
65 + missingBreakdown: !hasBreakdown,
66 + ariaLabel: `Context window ${percentLabel} used`,
67 + ringLabel: contextWindow ? `${Math.round(percent)}%` : "–",
68 + ringDasharray: `${Math.min(percent, 100)} 100`,
69 + summary: `${formatTokens(tokens)}/${contextWindow ? formatTokens(contextWindow) : "–"} (${percentLabel})`,
70 + meterStyle: `width:${Math.min(percent, 100)}%`,
71 + };
72 +}
73 +
74 +const model = {
75 + usage: buildUsage(),
76 + loadSeq: 0,
77 + open: false,
78 +
79 + get contextId() {
80 + return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
81 + },
82 +
83 + async onMount(watch) {
84 + await this.refresh();
85 + watch("$store.chats.selected", value => this.refresh(value || ""));
86 + watch("$store.chats.selectedContext?.running", (running, previous) => {
87 + if (previous && !running) void this.refresh();
88 + });
89 + },
90 +
91 + cleanup() {
92 + this.open = false;
93 + this.loadSeq += 1;
94 + },
95 +
96 + toggle() {
97 + this.open = !this.open;
98 + if (this.open) void this.refresh();
99 + },
100 +
101 + async refresh(contextId = this.contextId) {
102 + const requestSeq = ++this.loadSeq;
103 + if (!contextId) {
104 + this.usage = buildUsage();
105 + return this.usage;
106 + }
107 + try {
108 + const data = await callJsonApi(API_PATH, { context: contextId });
109 + if (requestSeq === this.loadSeq) this.usage = buildUsage(data);
110 + } catch (error) {
111 + if (requestSeq === this.loadSeq) console.error("Context window load failed:", error);
112 + }
113 + return this.usage;
114 + },
115 +};
116 +
117 +export const store = createStore("contextWindow", model);
plugins/_context_window/webui/thumbnail.webp
Binary files /dev/null and b/plugins/_context_window/webui/thumbnail.webp differ
tests/test_ui_control_visibility.py
+12
@@ -18,6 +18,13 @@ def test_ui_controls_have_independent_mobile_and_desktop_visibility() -> None:
18 interface = read("webui/components/settings/agent/interface.html")
19 chat_top = read("webui/components/chat/top-section/chat-top.html")
20 canvas = read("webui/components/canvas/right-canvas.html")
21 + context_usage = read(
22 + "plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html"
23 + )
24 + context_settings = read(
25 + "plugins/_context_window/extensions/webui/interface-controls-end/context-window.html"
26 + )
27 + context_store = read("plugins/_context_window/webui/context-window-store.js")
28 index = read("webui/index.html")
29 ui_server = read("helpers/ui_server.py")
30
@@ -26,6 +33,8 @@ def test_ui_controls_have_independent_mobile_and_desktop_visibility() -> None:
33 assert control in settings_store
34
35 assert "registerUiControlVisibility" in preferences
36 + assert "contextWindowUsage" in context_store
37 + assert "contextWindowUsage" in context_settings
38 assert 'id="interface-controls-end"' in interface
39
40 assert "section-interface" in settings_store
@@ -43,6 +52,7 @@ def test_ui_controls_have_independent_mobile_and_desktop_visibility() -> None:
52 assert "isUiControlVisible('time')" in chat_top
53 assert "isUiControlVisible('connectionStatus')" in chat_top
54 assert "isUiControlVisible('projectSelector')" in chat_top
55 + assert "isUiControlVisible('contextWindowUsage')" in context_usage
56 assert "isUiControlVisible('rightCanvasRail')" in canvas
57
58
@@ -60,6 +70,7 @@ normalized = settings.normalize_settings({
70 **settings.get_default_settings(),
71 "ui_control_visibility": {
72 "time": {"mobile": True, "desktop": False},
73 + "contextWindowUsage": {"mobile": False, "desktop": True},
74 "projectSelector": "invalid",
75 "unknown": {"mobile": False},
76 },
@@ -78,5 +89,6 @@ print(json.dumps({"defaults": defaults, "normalized": normalized}))
89
90 assert defaults["time"] == {"mobile": False, "desktop": True}
91 assert normalized["time"] == {"mobile": True, "desktop": False}
92 + assert normalized["contextWindowUsage"] == {"mobile": False, "desktop": True}
93 assert normalized["projectSelector"] == {"mobile": True, "desktop": True}
94 assert "unknown" not in normalized