Skip to content

Commit 07eb5d4

Browse files
committed
feat(desktop): show context usage beside the model controls
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
1 parent 148326e commit 07eb5d4

9 files changed

Lines changed: 145 additions & 19 deletions

File tree

apps/desktop/renderer-architecture.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@
791791
"nonTriviaTokens": 1425
792792
},
793793
"src/renderer/app-shell.tsx": {
794-
"importDeclarations": 105,
794+
"importDeclarations": 104,
795795
"bridgePaths": {
796796
"window.maka.app.installUpdate": 1,
797797
"window.maka.app.retryUpdateDownload": 1,
@@ -975,7 +975,7 @@
975975
"@maka/core/onboarding-milestone": 1,
976976
"@maka/core/orchestration": 1,
977977
"@maka/core/project": 1,
978-
"@maka/core/session": 2,
978+
"@maka/core/session": 1,
979979
"@maka/core/session-revisions": 1,
980980
"@maka/core/settings": 1,
981981
"@maka/core/slash-command-catalog": 2,
@@ -984,8 +984,8 @@
984984
"@maka/ui/icons": 1,
985985
"react": 1
986986
},
987-
"importSpecifiers": 186,
988-
"nonTriviaTokens": 15725
987+
"importSpecifiers": 185,
988+
"nonTriviaTokens": 15715
989989
},
990990
"src/renderer/use-app-shell-composer-quotes.ts": {
991991
"importDeclarations": 3,

apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ async function mountRegion(): Promise<{
113113
composerRef: composer,
114114
directoryComposerProps: {},
115115
directoryPickerEnabled: false,
116+
messages: [],
116117
active: true,
117118
onboardingComposerHidden: false,
118119
activeInteraction: undefined,

apps/desktop/src/renderer/app-shell.tsx

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ import type {
3535
InlineReference,
3636
QuoteRef,
3737
} from '@maka/core/events';
38-
import type { SessionSummary } from '@maka/core/session';
3938
import type { OrchestrationMode } from '@maka/core/orchestration';
4039
import type { ChatDefaultPermissionMode } from '@maka/core/settings';
4140
import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog';
@@ -955,10 +954,9 @@ function AppShellContent({
955954
openModelPicker: openComposerModelPicker,
956955
refreshModelChoices: sessionHostConnections.refreshConnections,
957956
});
958-
const newChatProviderType = newChatModel
959-
? connections.find((connection) => connection.slug === newChatModel.llmConnectionSlug)?.providerType
960-
: undefined;
961-
957+
const newChatProviderType = connections.find(
958+
(connection) => connection.slug === newChatModel?.llmConnectionSlug,
959+
)?.providerType;
962960
// PR109d-b: turn footer actions per turn. Derived from the
963961
// materialized turn list (status + lineage descendants) + pending
964962
// mask. Per @kenji PR109d review: pending state prevents double-click
@@ -1167,15 +1165,13 @@ function AppShellContent({
11671165

11681166
// Transient placeholder while the real SessionSummary loads, so the composer
11691167
// does not flash a value the session never had.
1170-
const activeSessionForView: SessionSummary | undefined =
1171-
activeSession ??
1172-
(activeId
1173-
? pendingSessionView({
1174-
sessionId: activeId,
1175-
name: shellCopy.newConversation,
1176-
permissionMode: newTaskPermissionMode,
1177-
})
1178-
: undefined);
1168+
const activeSessionForView = activeSession ?? (activeId
1169+
? pendingSessionView({
1170+
sessionId: activeId,
1171+
name: shellCopy.newConversation,
1172+
permissionMode: newTaskPermissionMode,
1173+
})
1174+
: undefined);
11791175
// Each control reads its own field. There is nothing to project and nothing
11801176
// to keep in sync: a Session in Plan with Swarm as its orchestration default
11811177
// says both, because it is both.
@@ -3003,6 +2999,7 @@ function AppShellContent({
30032999
activeModel={activeModel}
30043000
activeModelLabel={activeModelLabel}
30053001
activeProviderType={activeConnection?.providerType}
3002+
messages={messages}
30063003
modelChoices={chatModelChoices}
30073004
modelSwitchHasHistory={modelSwitchHasHistory}
30083005
hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'}

apps/desktop/src/renderer/chat-composer-region.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ interface ChatComposerRegionProps
114114
respondToUserQuestion: ComponentProps<typeof UserQuestionPrompt>['onRespond'];
115115
stop: ComponentProps<typeof UserQuestionPrompt>['onStop'];
116116
boundaryUnreadableNotice?: BoundaryUnreadableNotice;
117+
messages: readonly {
118+
type: string;
119+
lastRequestAnchor?: { inputTokens: number; outputTokens?: number };
120+
}[];
117121
directoryComposerProps: Pick<
118122
ComponentProps<typeof Composer>,
119123
'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory'
@@ -135,6 +139,7 @@ export function ChatComposerRegion({
135139
respondToUserQuestion,
136140
stop,
137141
boundaryUnreadableNotice,
142+
messages,
138143
directoryComposerProps,
139144
directoryPickerEnabled,
140145
...composerRest
@@ -145,6 +150,27 @@ export function ChatComposerRegion({
145150
const activeClientCapability =
146151
activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined;
147152
const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
153+
const latestTokenUsage = [...messages].reverse().find((message) => message.type === 'token_usage');
154+
const latestRequestAnchor = latestTokenUsage?.lastRequestAnchor;
155+
const activeModelChoice = composerRest.activeModel
156+
? composerRest.modelChoices?.find(
157+
(choice) =>
158+
choice.connectionId === composerRest.activeModelConnectionId &&
159+
choice.model === composerRest.activeModel,
160+
)
161+
: undefined;
162+
const contextUsage = activeId
163+
? {
164+
usageTokens:
165+
latestRequestAnchor &&
166+
Number.isFinite(latestRequestAnchor.inputTokens) &&
167+
latestRequestAnchor.inputTokens > 0
168+
? latestRequestAnchor.inputTokens + Math.max(0, latestRequestAnchor.outputTokens ?? 0)
169+
: undefined,
170+
declaredContextWindow: activeModelChoice?.declaredContextWindow,
171+
metadataContextWindow: activeModelChoice?.contextWindow,
172+
}
173+
: undefined;
148174
const previousNewTaskDraftKey = useRef(newTaskDraftKey);
149175
useLayoutEffect(() => {
150176
const previous = previousNewTaskDraftKey.current;
@@ -254,6 +280,7 @@ export function ChatComposerRegion({
254280
<Composer
255281
ref={composerRef}
256282
{...composerRest}
283+
contextUsage={contextUsage}
257284
// AppShell carries staged attachments into both queued and steering
258285
// follow-ups. Other Composer hosts remain gated by default because a
259286
// text-only running-turn submission would leave attachments behind.

apps/desktop/src/renderer/styles/model-switcher.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@
2626
max-width: 100%;
2727
}
2828

29+
.maka-context-usage-indicator {
30+
display: inline-flex;
31+
align-items: center;
32+
gap: var(--space-1);
33+
min-width: 4ch;
34+
color: var(--muted-foreground);
35+
font: var(--maka-text-supporting);
36+
white-space: nowrap;
37+
}
38+
39+
.maka-context-usage-indicator svg {
40+
flex: 0 0 auto;
41+
}
42+
2943
/* The composer footer's model and thinking pickers are ghost-button
3044
DropdownMenus — the same toolbar primitive as + and permission, so their
3145
resting, hover, focus, and disabled chrome all derive from the Astryx

packages/core/src/__tests__/llm-connections.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,39 @@ test('chat model choices project exact vision support for attachment composition
288288
);
289289
});
290290

291+
test('chat model choices keep provider metadata separate from user context declarations', () => {
292+
const choices = chatModelChoicesFor([
293+
{
294+
connectionId: 'connection-context',
295+
slug: 'openai-compatible',
296+
name: 'OpenAI compatible',
297+
providerType: 'openai-compatible',
298+
enabled: true,
299+
defaultModel: 'declared-model',
300+
enabledModelIds: ['declared-model', 'reported-model'],
301+
models: [
302+
{ id: 'declared-model', contextWindow: 64_000, inputLimit: 48_000 },
303+
{ id: 'reported-model', contextWindow: 128_000 },
304+
],
305+
relayModelProfiles: { 'declared-model': { contextWindow: 32_000 } },
306+
createdAt: 1,
307+
updatedAt: 1,
308+
},
309+
]);
310+
311+
assert.deepEqual(
312+
choices.map(({ model, contextWindow, declaredContextWindow }) => ({
313+
model,
314+
contextWindow,
315+
declaredContextWindow,
316+
})),
317+
[
318+
{ model: 'declared-model', contextWindow: 64_000, declaredContextWindow: 32_000 },
319+
{ model: 'reported-model', contextWindow: 128_000, declaredContextWindow: undefined },
320+
],
321+
);
322+
});
323+
291324
test('provider recognition does not resolve inherited object members', () => {
292325
// `PROVIDER_REGISTRY` is an object literal, so plain indexing answers truthy
293326
// for `__proto__` / `toString` / `constructor` and they would read as

packages/core/src/chat-model-choice.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* under the License.
1818
*/
1919

20-
import { type ThinkingLevel } from './model-thinking.js';
20+
import { declaredContextWindow, type ThinkingLevel } from './model-thinking.js';
2121
import {
2222
offerableCatalogEntries,
2323
providerDefaultsOf,
@@ -40,6 +40,10 @@ export interface ChatModelChoice {
4040
thinkingLevels: readonly ThinkingLevel[];
4141
/** Exact capability projection used by model-facing attachment composition. */
4242
supportsVision?: boolean;
43+
/** Provider/model metadata shown beside the user-declared context setting. */
44+
contextWindow?: number;
45+
/** User-declared context target, if this model has one. */
46+
declaredContextWindow?: number;
4347
}
4448

4549
export function buildChatModelChoices(
@@ -50,6 +54,7 @@ export function buildChatModelChoices(
5054
const provider = providerDefaultsOf(connection.providerType);
5155
if (!provider) continue;
5256
for (const entry of offerableCatalogEntries(connection)) {
57+
const declaredWindow = declaredContextWindow(connection, entry.id);
5358
choices.push({
5459
connectionId: connection.connectionId,
5560
connectionSlug: connection.slug,
@@ -63,6 +68,8 @@ export function buildChatModelChoices(
6368
isDefault: entry.isDefault,
6469
thinkingLevels: entry.thinkingLevels,
6570
supportsVision: entry.supportsVision,
71+
...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
72+
...(declaredWindow !== undefined ? { declaredContextWindow: declaredWindow } : {}),
6673
});
6774
}
6875
}

packages/ui/src/composer.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { useMountedRef } from './use-mounted-ref.js';
3737
import {
3838
ICON_SIZE,
3939
ArrowUp,
40+
CircleGauge,
4041
FileText,
4142
ListTodo,
4243
Network,
@@ -397,6 +398,12 @@ export const Composer = forwardRef<
397398
noModelConnection?: boolean;
398399
/** Optional Host-aware replacement for the generic no-model hint. */
399400
noModelHint?: string;
401+
/** Read-only usage indicator for the active model's latest request. */
402+
contextUsage?: {
403+
usageTokens?: number;
404+
declaredContextWindow?: number;
405+
metadataContextWindow?: number;
406+
};
400407
/**
401408
* Optional edit-and-resend banner above the composer. Desktop owns the
402409
* revision draft; Composer only renders the notice + cancel affordance.
@@ -2078,6 +2085,7 @@ export const Composer = forwardRef<
20782085
onChange={props.onNewChatThinkingLevelChange}
20792086
/>
20802087
)}
2088+
{props.contextUsage ? <ContextUsageIndicator {...props.contextUsage} /> : null}
20812089
</div>
20822090
{/* The project decides where a NEW chat starts, which makes it a
20832091
parameter of this send like the model beside it — so it sits
@@ -2177,4 +2185,31 @@ export const Composer = forwardRef<
21772185
);
21782186
});
21792187

2188+
function ContextUsageIndicator(props: {
2189+
usageTokens?: number;
2190+
declaredContextWindow?: number;
2191+
metadataContextWindow?: number;
2192+
}) {
2193+
const copy = getConversationCopy(useUiLocale()).messages;
2194+
const label =
2195+
props.usageTokens === undefined
2196+
? '—'
2197+
: props.declaredContextWindow !== undefined
2198+
? `${Math.round((props.usageTokens / props.declaredContextWindow) * 100)}%`
2199+
: `${props.usageTokens} tok`;
2200+
const tooltip =
2201+
props.usageTokens === undefined
2202+
? copy.systemNotes.contextUsageUnavailable
2203+
: props.declaredContextWindow === undefined
2204+
? copy.systemNotes.contextUsageProviderUnknown(props.metadataContextWindow)
2205+
: undefined;
2206+
const indicator = (
2207+
<span className="maka-context-usage-indicator" aria-label={tooltip ?? label}>
2208+
<CircleGauge size={ICON_SIZE.meta} aria-hidden="true" />
2209+
<span>{label}</span>
2210+
</span>
2211+
);
2212+
return tooltip ? <Tooltip content={tooltip}>{indicator}</Tooltip> : indicator;
2213+
}
2214+
21802215
export type ComposerProps = ComponentProps<typeof Composer>;

packages/ui/src/conversation-copy.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,8 @@ export interface ConversationCopy {
322322
contextCompactionFailedOpen: string;
323323
contextProviderDropping: string;
324324
contextWindowSuggestion: (tokens: number, declared: number | undefined) => string;
325+
contextUsageProviderUnknown: (metadata: number | undefined) => string;
326+
contextUsageUnavailable: string;
325327
stepLimit: string;
326328
};
327329
};
@@ -539,6 +541,11 @@ const CONVERSATION_COPY = {
539541
declared === undefined
540542
? `供应商拒绝了这次请求。该模型未声明上下文窗口;上次成功的用量约 ${tokens} tokens,可将窗口设为该值让 Maka 先行压缩。`
541543
: `供应商拒绝了这次请求,但用量(约 ${tokens} tokens)低于你声明的窗口(${declared})。声明值可能大于供应商实际窗口,建议下调到 ${tokens}。`,
544+
contextUsageProviderUnknown: (metadata) =>
545+
metadata === undefined
546+
? '该模型未声明窗口'
547+
: `该模型未声明窗口;模型声明 ${metadata}`,
548+
contextUsageUnavailable: '供应商未报告用量',
542549
stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。',
543550
},
544551
},
@@ -702,6 +709,11 @@ const CONVERSATION_COPY = {
702709
declared === undefined
703710
? `The provider rejected this request. No context window is declared for this model; the last accepted usage was about ${tokens} tokens — set the window to that value so Maka compacts first.`
704711
: `The provider rejected this request at about ${tokens} tokens, below your declared window (${declared}). The declared value is likely larger than the provider's; consider lowering it to ${tokens}.`,
712+
contextUsageProviderUnknown: (metadata) =>
713+
metadata === undefined
714+
? 'No context window is declared for this model'
715+
: `No context window is declared for this model; model metadata declares ${metadata}`,
716+
contextUsageUnavailable: 'The provider did not report usage',
705717
stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.',
706718
},
707719
},

0 commit comments

Comments
 (0)