You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/03 15:29:38 UTC

(maka) branch main updated: feat(desktop): show context usage beside the model controls (#4576)

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new b9748a7733 feat(desktop): show context usage beside the model controls (#4576)
b9748a7733 is described below

commit b9748a7733459c309faf1039721a8fccc84dc59d
Author: Yunpei Zhou <Jo...@outlook.com>
AuthorDate: Thu Sep 3 23:29:33 2026 +0800

    feat(desktop): show context usage beside the model controls (#4576)
    
    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 (#4559).
    
    Refs #4559
    
    Generated-by: Claude Code
    Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
---
 CHANGELOG.md                                       |   1 +
 apps/desktop/renderer-architecture.json            |   6 +-
 .../chat-composer-region-draft-handoff.test.ts     |   1 +
 .../main/__tests__/latest-request-usage.test.ts    | 124 +++++++++++++++++++++
 apps/desktop/src/renderer/app-shell.tsx            |  30 +++--
 apps/desktop/src/renderer/chat-composer-region.tsx |  73 ++++++++++++
 .../desktop/src/renderer/styles/model-switcher.css |  14 +++
 .../core/src/__tests__/llm-connections.test.ts     |  33 ++++++
 .../usage-record-last-request-anchor.test.ts       |   9 ++
 packages/core/src/chat-model-choice.ts             |   9 +-
 packages/core/src/usage-record-schema.ts           |  13 ++-
 packages/runtime-host/src/protocol/index.ts        |   5 +-
 packages/runtime/src/ai-sdk-backend.ts             |   4 +
 packages/ui/src/composer.tsx                       |  41 +++++++
 packages/ui/src/conversation-copy.ts               |  11 ++
 15 files changed, 351 insertions(+), 23 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf5ff4e9cb..bab1cf0dc6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,6 +48,7 @@
   owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
   and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
 - Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Bef [...]
+- `token_usage` anchors now record the model and connection that produced them. A token count is a number in one model's tokenizer against one connection; carrying the route on the record lets any reader apply the rule the runtime already enforces, instead of pairing one model's usage with another model's window. The record decodes against a closed allowlist, so sessions written with these keys do not open in earlier releases, and the Runtime Host compatibility epoch moves to 107.
 - Let the provider decide whether a request fits. Proactive compaction now uses only a user-declared Maka window and the previous accepted request's provider-reported `inputTokens + outputTokens`; no declaration means no proactive capacity threshold. `/models` and generated model metadata are display hints, not limits. `token_usage` records persist the last-request anchor under `lastRequestAnchor`; its new `{ inputTokens, outputTokens }` shape still decodes the retired `payloadChars` key [...]
 - Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune or disable compaction and Tool Result pruning; model-visible archive placeholders are read on demand through bounded `ArchiveRead` calls instead of eager hydration. Previously supported overrides are ignored on upgrade: if Tool Result pruning was set to `off`, pruning is re-enabled, and there is currently no supported replacement opt-out.
 - Moved Read image snapshots into the durable context-offload store with Runtime-owned
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 24d0367fae..669e5507ae 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -788,7 +788,7 @@
         "nonTriviaTokens": 1410
       },
       "src/renderer/app-shell.tsx": {
-        "importDeclarations": 103,
+        "importDeclarations": 102,
         "bridgePaths": {
           "window.maka.app.installUpdate": 1,
           "window.maka.app.retryUpdateDownload": 1,
@@ -970,7 +970,7 @@
           "@maka/core/onboarding-milestone": 1,
           "@maka/core/orchestration": 1,
           "@maka/core/project": 1,
-          "@maka/core/session": 2,
+          "@maka/core/session": 1,
           "@maka/core/session-revisions": 1,
           "@maka/core/settings": 1,
           "@maka/core/slash-command-catalog": 2,
@@ -980,7 +980,7 @@
           "react": 1
         },
         "importSpecifiers": 184,
-        "nonTriviaTokens": 15692
+        "nonTriviaTokens": 15687
       },
       "src/renderer/use-app-shell-composer-quotes.ts": {
         "importDeclarations": 3,
diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
index a6f0302ac7..907c24243d 100644
--- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
+++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
@@ -113,6 +113,7 @@ async function mountRegion(): Promise<{
               composerRef: composer,
               directoryComposerProps: {},
               directoryPickerEnabled: false,
+
               active: true,
               onboardingComposerHidden: false,
               activeInteraction: undefined,
diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts
new file mode 100644
index 0000000000..a77f4dbb35
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { selectLatestRequestUsage } from '../../renderer/chat-composer-region.js';
+
+const ROUTE = { llmConnectionId: 'conn-a' };
+const MODEL = 'model-a';
+
+function usage(anchor?: {
+  inputTokens: number;
+  outputTokens?: number;
+  modelId?: string;
+  connectionId?: string;
+}) {
+  return { type: 'token_usage', ...(anchor ? { lastRequestAnchor: anchor } : {}) };
+}
+
+test('reads the newest anchor on the active route', () => {
+  const tokens = selectLatestRequestUsage(
+    [
+      usage({ inputTokens: 10, outputTokens: 2, modelId: MODEL, connectionId: 'conn-a' }),
+      { type: 'assistant' },
+      usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }),
+    ],
+    { hasNewer: false },
+    MODEL,
+    ROUTE,
+  );
+  assert.equal(tokens, 120);
+});
+
+test('scans past an anchorless usage row, which is what manual compaction writes', () => {
+  // `/compact` appends a synthetic `token_usage` with no anchor. The runtime's
+  // own reader skips it and keeps the last real request; stopping there would
+  // blank the indicator after every manual compaction.
+  const tokens = selectLatestRequestUsage(
+    [
+      usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }),
+      usage(),
+    ],
+    { hasNewer: false },
+    MODEL,
+    ROUTE,
+  );
+  assert.equal(tokens, 120);
+});
+
+test('refuses an anchor from another model', () => {
+  // A token count is a number in one model's tokenizer. Pairing model A's
+  // count with model B's window produces a precise-looking figure about a
+  // request the user is not making.
+  const tokens = selectLatestRequestUsage(
+    [usage({ inputTokens: 100_000, modelId: 'model-b', connectionId: 'conn-a' })],
+    { hasNewer: false },
+    MODEL,
+    ROUTE,
+  );
+  assert.equal(tokens, undefined);
+});
+
+test('refuses an anchor from another connection', () => {
+  const tokens = selectLatestRequestUsage(
+    [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-b' })],
+    { hasNewer: false },
+    MODEL,
+    ROUTE,
+  );
+  assert.equal(tokens, undefined);
+});
+
+test('refuses an anchor written before anchors carried their route', () => {
+  const tokens = selectLatestRequestUsage(
+    [usage({ inputTokens: 100, outputTokens: 20 })],
+    { hasNewer: false },
+    MODEL,
+    ROUTE,
+  );
+  assert.equal(tokens, undefined);
+});
+
+test('refuses every anchor while the loaded range is not the session tail', () => {
+  // Browsing history must not report an older range's usage as current.
+  const tokens = selectLatestRequestUsage(
+    [usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' })],
+    { hasNewer: true },
+    MODEL,
+    ROUTE,
+  );
+  assert.equal(tokens, undefined);
+});
+
+test('refuses when there is no active route yet', () => {
+  const anchored = [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-a' })];
+  assert.equal(selectLatestRequestUsage(anchored, undefined, undefined, ROUTE), undefined);
+  assert.equal(selectLatestRequestUsage(anchored, undefined, MODEL, undefined), undefined);
+});
+
+test('refuses a non-positive input count', () => {
+  const tokens = selectLatestRequestUsage(
+    [usage({ inputTokens: 0, modelId: MODEL, connectionId: 'conn-a' })],
+    { hasNewer: false },
+    MODEL,
+    ROUTE,
+  );
+  assert.equal(tokens, undefined);
+});
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index a847a21794..0a685bbab4 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -35,7 +35,6 @@ import type {
   InlineReference,
   QuoteRef,
 } from '@maka/core/events';
-import type { SessionSummary } from '@maka/core/session';
 import type { OrchestrationMode } from '@maka/core/orchestration';
 import type { ChatDefaultPermissionMode } from '@maka/core/settings';
 import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog';
@@ -86,7 +85,7 @@ import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery
 import { LiveTurnReconciler } from './live-turn-reconciler';
 import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads';
 import { AgentGraphPanel } from './agent-graph-panel';
-import { ChatComposerRegion } from './chat-composer-region';
+import { ChatComposerRegion, selectLatestRequestUsage } from './chat-composer-region';
 import {
   WorkbarHost,
   WorkbarTitlebarActions,
@@ -361,8 +360,7 @@ function AppShellContent({
     sessionUiController,
   } = useAppShellSessionWorkspace(toastApi);
   const activeCatalogSession = sessions.find((session) => session.id === activeId);
-  const sharedSessionActive =
-    (activeCatalogSession as DesktopSessionSummary | undefined)?.shared === true;
+  const sharedSessionActive = activeCatalogSession?.shared === true;
   const ownerActiveId = activeCatalogSession && !sharedSessionActive ? activeId : undefined;
   const interactionHydrationEpochRef = useRef(new Map<string, number>());
   const markInteractionChanged = useCallback((sessionId: string) => {
@@ -952,10 +950,9 @@ function AppShellContent({
     openModelPicker: openComposerModelPicker,
     refreshModelChoices: sessionHostConnections.refreshConnections,
   });
-  const newChatProviderType = newChatModel
-    ? connections.find((connection) => connection.slug === newChatModel.llmConnectionSlug)?.providerType
-    : undefined;
-
+  const newChatProviderType = connections.find(
+    (connection) => connection.slug === newChatModel?.llmConnectionSlug,
+  )?.providerType;
   // PR109d-b: turn footer actions per turn. Derived from the
   // materialized turn list (status + lineage descendants) + pending
   // mask. Per @kenji PR109d review: pending state prevents double-click
@@ -1160,15 +1157,13 @@ function AppShellContent({
 
   // Transient placeholder while the real SessionSummary loads, so the composer
   // does not flash a value the session never had.
-  const activeSessionForView: SessionSummary | undefined =
-    activeSession ??
-    (activeId
-      ? pendingSessionView({
-          sessionId: activeId,
-          name: shellCopy.newConversation,
-          permissionMode: newTaskPermissionMode,
-        })
-      : undefined);
+  const activeSessionForView = activeSession ?? (activeId
+    ? pendingSessionView({
+        sessionId: activeId,
+        name: shellCopy.newConversation,
+        permissionMode: newTaskPermissionMode,
+      })
+    : undefined);
   // Each control reads its own field. There is nothing to project and nothing
   // to keep in sync: a Session in Plan with Swarm as its orchestration default
   // says both, because it is both.
@@ -2996,6 +2991,7 @@ function AppShellContent({
                   activeModel={activeModel}
                   activeModelLabel={activeModelLabel}
                   activeProviderType={activeConnection?.providerType}
+                  latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)}
                   modelChoices={chatModelChoices}
                   modelSwitchHasHistory={modelSwitchHasHistory}
                   hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'}
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 2c25797fd7..b2ab639c27 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -114,6 +114,13 @@ interface ChatComposerRegionProps
   respondToUserQuestion: ComponentProps<typeof UserQuestionPrompt>['onRespond'];
   stop: ComponentProps<typeof UserQuestionPrompt>['onStop'];
   boundaryUnreadableNotice?: BoundaryUnreadableNotice;
+  /**
+   * Tokens the provider counted for the session's latest request on the active
+   * route, or nothing when that cannot be established. Resolved by the owner,
+   * which knows the transcript range and the route; this control never derives
+   * it from the rendered slice.
+   */
+  latestRequestUsageTokens?: number;
   directoryComposerProps: Pick<
     ComponentProps<typeof Composer>,
     'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory'
@@ -121,6 +128,56 @@ interface ChatComposerRegionProps
   directoryPickerEnabled: boolean;
 }
 
+/**
+ * The session's latest provider-counted request, or nothing.
+ *
+ * A token count belongs to one request on one route: it is a number in that
+ * model's tokenizer, and it is only the session's latest if nothing newer
+ * exists. The runtime enforces both when it reads an anchor back, refusing one
+ * whose run header names another model or connection. A control that shows the
+ * number has to enforce the same two facts or it will display a precise-looking
+ * figure about a request the user is not making — model A's tokens against
+ * model B's window, or a historical range's usage presented as current.
+ *
+ * So this refuses rather than approximates, and the three refusals are the
+ * three normal states that break the pairing:
+ *
+ * - the loaded transcript range is not the session tail, so a newer request may
+ *   exist that this range cannot see;
+ * - the newest usage row carries no anchor, which is what manual `/compact`
+ *   writes, so the scan continues past it exactly as the runtime's does;
+ * - the anchor names a different route than the active one, or names none at
+ *   all because it was written before anchors carried their route.
+ */
+export interface LatestRequestUsageAnchor {
+  inputTokens: number;
+  outputTokens?: number;
+  modelId?: string;
+  connectionId?: string;
+}
+
+export function selectLatestRequestUsage(
+  messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[],
+  /** `hasNewer` means the loaded range is not the session tail. */
+  range: { hasNewer?: boolean } | undefined,
+  model: string | undefined,
+  route: { llmConnectionId?: string } | undefined,
+): number | undefined {
+  const connectionId = route?.llmConnectionId;
+  if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined;
+  for (let index = messages.length - 1; index >= 0; index -= 1) {
+    const message = messages[index];
+    if (message?.type !== 'token_usage') continue;
+    const anchor = message.lastRequestAnchor;
+    if (!anchor) continue;
+    if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined;
+    if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined;
+    const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0;
+    return anchor.inputTokens + output;
+  }
+  return undefined;
+}
+
 export function ChatComposerRegion({
   composerRef,
   active,
@@ -135,6 +192,7 @@ export function ChatComposerRegion({
   respondToUserQuestion,
   stop,
   boundaryUnreadableNotice,
+  latestRequestUsageTokens,
   directoryComposerProps,
   directoryPickerEnabled,
   ...composerRest
@@ -145,6 +203,20 @@ export function ChatComposerRegion({
   const activeClientCapability =
     activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined;
   const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
+  const activeModelChoice = composerRest.activeModel
+    ? composerRest.modelChoices?.find(
+        (choice) =>
+          choice.connectionId === composerRest.activeModelConnectionId &&
+          choice.model === composerRest.activeModel,
+      )
+    : undefined;
+  const contextUsage = activeId
+    ? {
+        usageTokens: latestRequestUsageTokens,
+        declaredContextWindow: activeModelChoice?.declaredContextWindow,
+        metadataContextWindow: activeModelChoice?.contextWindow,
+      }
+    : undefined;
   const previousNewTaskDraftKey = useRef(newTaskDraftKey);
   useLayoutEffect(() => {
     const previous = previousNewTaskDraftKey.current;
@@ -254,6 +326,7 @@ export function ChatComposerRegion({
           <Composer
             ref={composerRef}
             {...composerRest}
+            contextUsage={contextUsage}
             // AppShell carries staged attachments into both queued and steering
             // follow-ups. Other Composer hosts remain gated by default because a
             // text-only running-turn submission would leave attachments behind.
diff --git a/apps/desktop/src/renderer/styles/model-switcher.css b/apps/desktop/src/renderer/styles/model-switcher.css
index 06b95b61a2..253304bf1f 100644
--- a/apps/desktop/src/renderer/styles/model-switcher.css
+++ b/apps/desktop/src/renderer/styles/model-switcher.css
@@ -26,6 +26,20 @@
   max-width: 100%;
 }
 
+.maka-context-usage-indicator {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--space-1);
+  min-width: 4ch;
+  color: var(--muted-foreground);
+  font: var(--maka-text-supporting);
+  white-space: nowrap;
+}
+
+.maka-context-usage-indicator svg {
+  flex: 0 0 auto;
+}
+
 /* The composer footer's model and thinking pickers are ghost-button
    DropdownMenus — the same toolbar primitive as + and permission, so their
    resting, hover, focus, and disabled chrome all derive from the Astryx
diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts
index e17aaf28f8..97cce94cb9 100644
--- a/packages/core/src/__tests__/llm-connections.test.ts
+++ b/packages/core/src/__tests__/llm-connections.test.ts
@@ -288,6 +288,39 @@ test('chat model choices project exact vision support for attachment composition
   );
 });
 
+test('chat model choices keep provider metadata separate from user context declarations', () => {
+  const choices = chatModelChoicesFor([
+    {
+      connectionId: 'connection-context',
+      slug: 'openai-compatible',
+      name: 'OpenAI compatible',
+      providerType: 'openai-compatible',
+      enabled: true,
+      defaultModel: 'declared-model',
+      enabledModelIds: ['declared-model', 'reported-model'],
+      models: [
+        { id: 'declared-model', contextWindow: 64_000, inputLimit: 48_000 },
+        { id: 'reported-model', contextWindow: 128_000 },
+      ],
+      relayModelProfiles: { 'declared-model': { contextWindow: 32_000 } },
+      createdAt: 1,
+      updatedAt: 1,
+    },
+  ]);
+
+  assert.deepEqual(
+    choices.map(({ model, contextWindow, declaredContextWindow }) => ({
+      model,
+      contextWindow,
+      declaredContextWindow,
+    })),
+    [
+      { model: 'declared-model', contextWindow: 64_000, declaredContextWindow: 32_000 },
+      { model: 'reported-model', contextWindow: 128_000, declaredContextWindow: undefined },
+    ],
+  );
+});
+
 test('provider recognition does not resolve inherited object members', () => {
   // `PROVIDER_REGISTRY` is an object literal, so plain indexing answers truthy
   // for `__proto__` / `toString` / `constructor` and they would read as
diff --git a/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts b/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts
index c8aba6ee6e..e37c5af83e 100644
--- a/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts
+++ b/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts
@@ -48,6 +48,15 @@ test('token-usage fields carry the anchor and reject a broken one', () => {
     isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120, foo: 1 } }),
     false,
   );
+  // The route the counts belong to. A reader pairs them with a window only
+  // when it matches the request it is about to make.
+  assert.equal(
+    isTokenUsageFields({
+      ...usage,
+      lastRequestAnchor: { inputTokens: 120, modelId: 'm', connectionId: 'c' },
+    }),
+    true,
+  );
 });
 
 test('an invalid anchor fails the whole token_usage message decode', () => {
diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts
index ff628fd5b8..bdd94872fb 100644
--- a/packages/core/src/chat-model-choice.ts
+++ b/packages/core/src/chat-model-choice.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { type ThinkingLevel } from './model-thinking.js';
+import { declaredContextWindow, type ThinkingLevel } from './model-thinking.js';
 import {
   offerableCatalogEntries,
   providerDefaultsOf,
@@ -40,6 +40,10 @@ export interface ChatModelChoice {
   thinkingLevels: readonly ThinkingLevel[];
   /** Exact capability projection used by model-facing attachment composition. */
   supportsVision?: boolean;
+  /** Provider/model metadata shown beside the user-declared context setting. */
+  contextWindow?: number;
+  /** User-declared context target, if this model has one. */
+  declaredContextWindow?: number;
 }
 
 export function buildChatModelChoices(
@@ -50,6 +54,7 @@ export function buildChatModelChoices(
     const provider = providerDefaultsOf(connection.providerType);
     if (!provider) continue;
     for (const entry of offerableCatalogEntries(connection)) {
+      const declaredWindow = declaredContextWindow(connection, entry.id);
       choices.push({
         connectionId: connection.connectionId,
         connectionSlug: connection.slug,
@@ -63,6 +68,8 @@ export function buildChatModelChoices(
         isDefault: entry.isDefault,
         thinkingLevels: entry.thinkingLevels,
         supportsVision: entry.supportsVision,
+        ...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
+        ...(declaredWindow !== undefined ? { declaredContextWindow: declaredWindow } : {}),
       });
     }
   }
diff --git a/packages/core/src/usage-record-schema.ts b/packages/core/src/usage-record-schema.ts
index da41aa1234..f3c2d0e5cc 100644
--- a/packages/core/src/usage-record-schema.ts
+++ b/packages/core/src/usage-record-schema.ts
@@ -307,11 +307,22 @@ export interface TokenUsageFields {
 export interface LastRequestAnchor {
   inputTokens: number;
   outputTokens?: number;
+  /**
+   * The route that produced these counts.
+   *
+   * A token count is a number in one model's tokenizer against one connection.
+   * The runtime already refuses an anchor across a route change, validating it
+   * against the run header; carrying the route on the record lets every other
+   * reader apply the same rule without reconstructing run headers, and without
+   * pairing counts from one model with another model's window.
+   */
+  modelId?: string;
+  connectionId?: string;
 }
 
 const LAST_REQUEST_ANCHOR_SHAPE = defineObjectShape<LastRequestAnchor>()(
   ['inputTokens'],
-  ['outputTokens'],
+  ['outputTokens', 'modelId', 'connectionId'],
 );
 const RETIRED_LAST_REQUEST_ANCHOR_KEYS = ['payloadChars'] as const;
 const LAST_REQUEST_ANCHOR_DECODE_SHAPE = {
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 077a0d58c6..aca388a526 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -100,7 +100,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 106 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 107 as const;
+// 107: `token_usage` anchors record the model and connection that produced
+// them. The record decodes against a closed allowlist, so an older client
+// rejects the two new keys and, with them, the Session that carries them.
 // 106: Session transcripts gain five `system_note` kinds
 // (`context_provider_dropping`, `context_window_suggestion`,
 // `context_window_overrun`, `context_reported_window_exceeded`,
diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts
index f5da71ea18..4f2db9e6d4 100644
--- a/packages/runtime/src/ai-sdk-backend.ts
+++ b/packages/runtime/src/ai-sdk-backend.ts
@@ -2975,6 +2975,10 @@ export class AiSdkBackend implements AgentBackend {
                       ...(anchorOutputTokens !== undefined
                         ? { outputTokens: anchorOutputTokens }
                         : {}),
+                      modelId: this.input.modelId,
+                      ...(this.input.header.llmConnectionId !== undefined
+                        ? { connectionId: this.input.header.llmConnectionId }
+                        : {}),
                     },
                   }
                 : {}),
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index fe19b9ea86..5782a857ea 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -37,6 +37,7 @@ import { useMountedRef } from './use-mounted-ref.js';
 import {
   ICON_SIZE,
   ArrowUp,
+  CircleGauge,
   FileText,
   ListTodo,
   Network,
@@ -397,6 +398,12 @@ export const Composer = forwardRef<
     noModelConnection?: boolean;
     /** Optional Host-aware replacement for the generic no-model hint. */
     noModelHint?: string;
+    /** Read-only usage indicator for the active model's latest request. */
+    contextUsage?: {
+      usageTokens?: number;
+      declaredContextWindow?: number;
+      metadataContextWindow?: number;
+    };
     /**
      * Optional edit-and-resend banner above the composer. Desktop owns the
      * revision draft; Composer only renders the notice + cancel affordance.
@@ -2135,6 +2142,7 @@ export const Composer = forwardRef<
                     onChange={props.onNewChatThinkingLevelChange}
                   />
                 )}
+                {props.contextUsage ? <ContextUsageIndicator {...props.contextUsage} /> : null}
               </div>
               {/* The project decides where a NEW chat starts, which makes it a
                   parameter of this send like the model beside it — so it sits
@@ -2234,4 +2242,37 @@ export const Composer = forwardRef<
   );
 });
 
+function ContextUsageIndicator(props: {
+  usageTokens?: number;
+  declaredContextWindow?: number;
+  metadataContextWindow?: number;
+}) {
+  const copy = getConversationCopy(useUiLocale()).messages;
+  // A window from either source is enough to show a share: the user's
+  // declaration when there is one, otherwise the model's reported window. The
+  // distinction matters for the compaction threshold, which only a declaration
+  // arms, not for reading a number off the screen. With no window at all the
+  // usage stands on its own.
+  const window = props.declaredContextWindow ?? props.metadataContextWindow;
+  const label =
+    props.usageTokens === undefined
+      ? '—'
+      : window !== undefined && window > 0
+        ? `${Math.round((props.usageTokens / window) * 100)}%`
+        : `${props.usageTokens} tok`;
+  const tooltip =
+    props.usageTokens === undefined
+      ? copy.systemNotes.contextUsageUnavailable
+      : window !== undefined && window > 0
+        ? copy.systemNotes.contextUsageShare(props.usageTokens, window)
+        : copy.systemNotes.contextUsageNoWindow;
+  const indicator = (
+    <span className="maka-context-usage-indicator" aria-label={tooltip ?? label}>
+      <CircleGauge size={ICON_SIZE.meta} aria-hidden="true" />
+      <span>{label}</span>
+    </span>
+  );
+  return tooltip ? <Tooltip content={tooltip}>{indicator}</Tooltip> : indicator;
+}
+
 export type ComposerProps = ComponentProps<typeof Composer>;
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index 41946eb14a..966847ebe8 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -325,6 +325,9 @@ export interface ConversationCopy {
       contextWindowOverrun: (used: number, declared: number) => string;
       contextReportedWindowExceeded: (used: number, reported: number) => string;
       contextOverflowAfterCompaction: string;
+      contextUsageShare: (used: number, window: number) => string;
+      contextUsageNoWindow: string;
+      contextUsageUnavailable: string;
       stepLimit: string;
     };
   };
@@ -549,6 +552,10 @@ const CONVERSATION_COPY = {
           `本次交换用了约 ${used} tokens,已超过该模型上报的窗口(${reported}),但供应商没有拒绝。你未声明窗口,Maka 因此不会主动压缩。在连接设置里声明一个窗口即可让它先行压缩。`,
         contextOverflowAfterCompaction:
           '已经压缩过历史,供应商仍然说这次请求太大。剩下的部分还包含系统提示、工具定义、摘要和最近的原文,缩短这条消息是你能控制的那一半。',
+        contextUsageShare: (used, window) =>
+          `${used.toLocaleString('zh-CN')} / ${window.toLocaleString('zh-CN')} tokens`,
+        contextUsageNoWindow: '该模型没有窗口大小可用:未声明,模型也未上报',
+        contextUsageUnavailable: '供应商未报告用量',
         stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。',
       },
     },
@@ -718,6 +725,10 @@ const CONVERSATION_COPY = {
           `This exchange used about ${used} tokens, past the ${reported} this model reports, and the provider accepted it without complaint. Nothing is declared, so Maka does not compact on its own. Declare a context window in the connection settings to have it compact first.`,
         contextOverflowAfterCompaction:
           'History was compacted and the provider still called this request too large. What remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.',
+        contextUsageShare: (used, window) =>
+          `${used.toLocaleString('en-US')} / ${window.toLocaleString('en-US')} tokens`,
+        contextUsageNoWindow: 'No context window size is available: none declared, none reported',
+        contextUsageUnavailable: 'The provider did not report usage',
         stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.',
       },
     },


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org