Skip to content

feat(desktop): show context usage beside the model controls - #4576

Draft
Joob1n wants to merge 8 commits into
apache:mainfrom
Joob1n:feat/context-usage-indicator
Draft

feat(desktop): show context usage beside the model controls#4576
Joob1n wants to merge 8 commits into
apache:mainfrom
Joob1n:feat/context-usage-indicator

Conversation

@Joob1n

@Joob1n Joob1n commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #4574 (the first commit here is that PR; review only the last one).

A read-only context-usage indicator in the composer's model controls, to the right of the thinking-level control. It shows the latest request as the provider counted it — input plus output tokens of the last accepted request, read from the session's newest token_usage record (lastRequestAnchor, the shape #4574 introduces) — and never a local estimate.

  • With a user-declared Maka window: a percentage. Over 100% is shown as such, not clamped — the Maka window is a target, not a limit.
  • Without one: the absolute token count, with a tooltip naming the model's reported window when the catalog has one, so the user knows what they could declare.
  • Without usage: a dash, with a tooltip saying the provider reported none.

buildChatModelChoices now carries the reported window (contextWindow) and the declared window (declaredContextWindow) as separate fields, resolved by core's single owner of the declaration rule, so the two are never confused downstream.

Refs #4559

Verification

npm --workspace @maka/{core,ui} run build, tsc --noEmit for core and ui, npm --workspace @maka/desktop run typecheck, npm run check:renderer-architecture -- --base origin/main, npm run check:app-shell-hooks, npm run astryx:surface-inventory, npm run lint, npm run format:check — clean. llm-connections.test.js 13/13. Not run locally: the desktop main suite (chat-composer-region-draft-handoff.test.ts gains the new required messages prop) and Desktop e2e — relying on CI.

Review focus

  • app-shell.tsx carries two small unrelated rewrites (newChatProviderType, the activeSessionForView annotation) that pay for the new messages prop under the renderer ratchet: the file's import declarations and specifiers go down by one each, so the debt counts do not increase. Say if you would rather see the ratchet entry adjusted instead.
  • chat-composer-region.tsx scans the message list backwards on each render to find the newest token_usage. It is a legacy-ratchet file, so a useMemo would count as a new hook call; the scan stops at the first match, which is normally the last message.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

The runtime no longer estimates whether a request fits a context window.
Every "does it fit" question is answered by a provider: the conversation
model's own context-length rejection is recovered by one compact-and-retry,
and the summarizer's provider answers for compaction input (input_too_large
retreats the fold by half). The chars/4 payload ruler, the signed delta
estimate, the 32,000-token fallback history budget, the quarter-window
reserve, the replacement-not-smaller and prefix-over-budget replay gates,
and the final-request rescue re-entry are removed.

Proactive compaction keeps one trigger: the previous accepted request's
real input plus output tokens, as the provider counted them, compared with
the context window the user declared for the model (a model-facts pin or a
relay profile). A provider's /models report and generated metadata are no
longer a threshold on their own. With no declaration there is no proactive
fold; the provider decides. A reply the provider cut at its output limit
(finishReason length) folds once before the next request.

The persisted last-request anchor becomes { inputTokens, outputTokens };
the retired payloadChars key still decodes so 0.2.0 sessions keep loading.
Summaries are capped at 8,000 output tokens with one shorter retry, and the
too-small-for-fold floor reads the summarizer call's real usage instead of
a chars/4 estimate. Two user-visible notes explain provider-side context
changes: context_provider_dropping (an append-only step whose usage did not
grow) and context_window_suggestion (a rejection at a proven-fit total,
with the number the user can declare).

Closes apache#4559
Refs apache#4458, apache#4486

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Usage is the only signal the runtime's context handling now reads: the
proactive compaction baseline, the eviction check, the window suggestion
and the usage indicator all start from the provider's own token counts.
A Chat Completions server returns none unless the request carries
`stream_options.include_usage`, and the factory only sent that for the two
providers whose registry entries opted in. A local Ollama and every plain
OpenAI-compatible relay — the connection type behind apache#4458 — therefore
produced no usage at all, and everything downstream stayed dark.

Live probe against Ollama (qwen2.5:0.5b): before, the finish chunk carried
an empty usage object and no token_usage event was emitted; after, every
turn reports its input and output and persists the anchor.

The default is now on for the OpenAI-compatible Chat adapter; a registry
entry can still opt out with `includeUsage: false`.

Refs apache#4559

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
… can answer

The folded span handed to the summarizer usually ends on an assistant
message. A chat-template model given a conversation that already ends with
its own turn emits an end-of-sequence token and nothing else — observed
live on Ollama with qwen2.5:0.5b and qwen2.5:1.5b: finish `stop`, one
output token, empty text, on every compaction attempt. The write gate then
fails the fold open, so history compaction never succeeded against a local
model. The request now closes with a user instruction to write the
structured summary; on the first attempt and on the repair.

Live against Ollama (qwen2.5:1.5b, declared 1,500-token window): the fold
that previously produced an empty summary now writes a 1,737-character
checkpoint after one repair, and the next request's provider-counted input
drops from 2,954 to 1,222 tokens.

Refs apache#4559

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
The two user-visible compaction notes keyed on the `priorReplay` stage only.
Since apache#4486 every new fold happens in the request-projection hook
(`activeStep`), so the turn that was actually compacted showed nothing and
the note arrived one turn later, when the checkpoint was replayed; a fold
that failed open in the hook was never surfaced at all. Live against Ollama
a fold succeeded and a fold failed in consecutive turns with no note either
time. Both predicates now accept a history-compaction decision from either
stage; the once-per-send flags in the backend are unchanged.

Refs apache#4559

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Review findings from apache#4574, each with a test that fails on the previous
commit:

- The proactive trigger now reserves the model's declared output limit
  (`connection.models[].maxOutputTokens` or generated metadata, a provider
  fact; 0 when none is declared) and fires on `>=`: with the window
  declared at the provider's real size, an accepted request alone can never
  exceed it, and the reply the next request must leave room for is what
  tips it. The window itself stays the user's declaration.
- The summarizer's provider is a decider again: a context-length rejection
  from the summary call (or the Codex compaction call) surfaces as
  `input_too_large`, the reason the planner retreats on. Removing the
  estimated pre-fit had left that path without a producer.
- The eviction check compares input against input: the previous reply's
  reasoning is not resent on every wire, so input + output is not a floor.
- The window suggestion is written after recovery, only when the turn
  surfaces the rejection, and names the last accepted total, which a fold
  does not clear. It is no longer a number on the `>` boundary.
- The size floor judges an initial fold only; on a roll-forward the
  summarizer's input is the increment, not the covered span.
- `CheckpointSummaryFoldContext` carries usage only; the builder and copy
  seams validate structure and truncation, which is all they can.
- The CLI transcript renders the two new note kinds.
- Restored coverage: a synthetic /compact usage row does not shadow the
  real anchor; the run-header check is exercised with an anchor above the
  window.
- CHANGELOG carries the downgrade and epoch sentences again.

The Host's reported `contextWindow` stays the model's window: it is a
protocol fact clients already display, and the declared window has its own
surface in the composer indicator.

Refs apache#4559

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
…audit

`npm audit --omit=dev` reports two advisories against production
dependencies: qs 6.15.3 (moderate: array-limit bypass via bracket-key comma
parsing; DoS via attacker-controlled isBuffer), reached through
@larksuiteoapi/node-sdk in @maka/runtime, and fast-uri 3.1.x (high: host
confusion and SSRF via IDN, IPv6 and percent-decoding normalization). The
"Build CLI release candidate" CI step and the Dependency audit workflow fail
on any such advisory, so every PR on main is red until the lockfile moves.
qs resolves to 6.16.0 and fast-uri to 3.1.7; the production dependency
notices are regenerated for the new versions.

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Two new system_note kinds and the reshaped lastRequestAnchor land in
@maka/core's closed allowlists. A merge-base client handshakes on strict
epoch equality and would then fail to decode the first transcript carrying
them; the epoch moves so the pair refuses each other at the handshake.

Refs apache#4559

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
A read-only indicator in the composer's model controls shows the latest
request as the provider counted it: input plus output tokens of the last
accepted request, read from the session's newest token_usage record. With
a user-declared Maka window it shows the percentage (over 100% is shown as
such, never clamped); without one it shows the absolute count and names the
model's reported window in a tooltip; without usage it shows a dash and
says the provider reported none. Chat model choices carry the reported and
the declared window separately so the two are never confused (apache#4559).

Refs apache#4559

Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1n force-pushed the feat/context-usage-indicator branch from 07eb5d4 to a4df755 Compare September 2, 2026 18:12
@Joob1n

Joob1n commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

The test failure here is the Desktop e2e race tracked in #4573 (transcript-scroll:316 and quote-selection:22, both element(s) not found after a send); nothing in this PR touches those paths, and the same two tests are failing on unrelated PRs today. Will rebase once #4577 lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Over 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant