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