Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions apps/desktop/src/components/ContextUsageInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
calculateContextUsage,
calculateTokenRate,
contextOccupancyTokens,
contextUsageView,
resolveContextUsageDisplay,
} from "../lib/context-usage";
import {
placeContextInspector,
Expand Down Expand Up @@ -65,6 +67,12 @@ export function ContextUsageInspector({
const [popoverPosition, setPopoverPosition] =
useState<ContextInspectorPlacement | null>(null);
const context = calculateContextUsage(usage, contextWindow);
// The display preference flips the leading figure only; capacity colors
// still follow remaining space so the warning state keeps one meaning.
const usageDisplay = useAppStore((state) =>
resolveContextUsageDisplay(state.settings?.contextUsageDisplay),
);
const display = contextUsageView(context, usageDisplay);
// Occupancy, turn total, and provider cache/input/output are the last
// model request. Summing every tool-loop call inflates cache read past
// the window (OpenCode last-message accounting).
Expand All @@ -87,7 +95,18 @@ export function ContextUsageInspector({
? "critical"
: context.remainingPercent <= 25
? "warning"
: "comfortable";
: "comfortable";
// One accessible sentence serves both display modes: the localized `state`
// phrase carries "remaining"/"used", so the key stays literal for the
// tooltip contract while `percent`/`count` stay numeric.
const ariaArguments = {
percent: display.percent,
count: formatTokenCount(display.tokens),
state:
display.display === "used"
? t("chat.usageContextAriaUsed")
: t("chat.usageContextAriaRemaining"),
};

const closeInspector = useCallback(() => {
setOpen(false);
Expand Down Expand Up @@ -229,12 +248,16 @@ export function ContextUsageInspector({
>
<div className="context-inspector-heading">
<strong className="context-inspector-heading-value">
{t("chat.usageContextLeft", {
count: formatTokenCount(context.remainingTokens),
})}
{display.display === "used"
? t("chat.usageContextSpent", {
count: formatTokenCount(display.tokens),
})
: t("chat.usageContextLeft", {
count: formatTokenCount(display.tokens),
})}
</strong>
<strong className="context-inspector-heading-percent">
{context.remainingPercent}%
{display.percent}%
</strong>
</div>
<div className="context-inspector-window">
Expand Down Expand Up @@ -336,14 +359,8 @@ export function ContextUsageInspector({
ref={triggerRef}
type="button"
className="context-inspector-trigger"
tooltip={t("chat.usageContextAria", {
percent: context.remainingPercent,
remaining: formatTokenCount(context.remainingTokens),
})}
ariaLabel={t("chat.usageContextAria", {
percent: context.remainingPercent,
remaining: formatTokenCount(context.remainingTokens),
})}
tooltip={t("chat.usageContextAria", ariaArguments)}
ariaLabel={t("chat.usageContextAria", ariaArguments)}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
Expand All @@ -367,12 +384,12 @@ export function ContextUsageInspector({
r={CONTEXT_RING_RADIUS}
strokeDasharray={CONTEXT_RING_CIRCUMFERENCE}
strokeDashoffset={
CONTEXT_RING_CIRCUMFERENCE * (1 - context.remainingRatio)
CONTEXT_RING_CIRCUMFERENCE * (1 - display.ratio)
}
/>
</svg>
<span className="context-inspector-ring-value">
{context.remainingPercent}%
{display.percent}%
</span>
</TooltipButton>
{popover && typeof document !== "undefined"
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/lib/context-usage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
effectiveContextWindow,
modelIdsMatch,
type ContextUsageDisplay,
type MessageUsage,
type ModelInfo,
type ProviderPublic,
Expand Down Expand Up @@ -120,6 +121,49 @@ export function calculateContextUsage(
};
}

/**
* Which figure the composer ring and its summary lead with (D398). Absent or
* unrecognised values keep the remaining-capacity default, so a persisted
* typo never blanks the trigger.
*/
export function resolveContextUsageDisplay(value: unknown): ContextUsageDisplay {
return value === "used" ? "used" : "remaining";
}

export type ContextUsageView = {
display: ContextUsageDisplay;
/** Percentage the trigger, heading, and popover lead with. */
percent: number;
/** Token count matching `percent`. */
tokens: number;
/** Ring arc fill, 0–1, matching `percent`. */
ratio: number;
};

/**
* Pick the leading percentage/token pair for the configured display mode.
* Capacity colors stay on `ContextUsage.remainingPercent` in both modes, so
* "used 78%" still warns when only 22% is left.
*/
export function contextUsageView(
usage: ContextUsage,
display: ContextUsageDisplay,
): ContextUsageView {
return display === "used"
? {
display,
percent: usage.usedPercent,
tokens: usage.usedTokens,
ratio: usage.usedRatio,
}
: {
display,
percent: usage.remainingPercent,
tokens: usage.remainingTokens,
ratio: usage.remainingRatio,
};
}

function serializedLength(value: unknown): number {
if (typeof value === "string") return value.length;
try {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/lib/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export const SETTINGS_NAV: SettingsNavEntry[] = [
"settings.commandShell",
"settings.linkOpenTarget",
"settings.enterToSend",
"settings.contextUsageDisplay",
"settings.contextUsageDisplayRemaining",
"settings.contextUsageDisplayUsed",
"settings.largePasteThreshold",
],
},
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
SETTINGS_NAV_GROUP_LABELS,
type SettingsNavGroupId,
} from "../lib/settings-search";
import { resolveContextUsageDisplay } from "../lib/context-usage";
import {
IconArchive,
IconBookOpen,
Expand Down Expand Up @@ -283,6 +284,53 @@ function LinkOpenTargetRow({
);
}

/**
* Which figure the composer context ring leads with (D398). Color thresholds
* stay on remaining capacity in both modes, so "used" never repaints the
* warning state.
*/
function ContextUsageDisplayRow({
settings,
saveSettings,
}: {
settings: AppSettings;
saveSettings: (patch: Partial<AppSettings>) => Promise<void>;
}) {
const { t } = useTranslation();
const current = resolveContextUsageDisplay(settings.contextUsageDisplay);
return (
<SettingsRow
title={t("settings.contextUsageDisplay")}
description={t("settings.contextUsageDisplayDesc")}
>
<div
className="settings-segment"
role="radiogroup"
aria-label={t("settings.contextUsageDisplay")}
>
{([
["remaining", "settings.contextUsageDisplayRemaining"],
["used", "settings.contextUsageDisplayUsed"],
] as const).map(([value, labelKey]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={current === value}
className={cx(
"settings-segment-item",
current === value && "active",
)}
onClick={() => void saveSettings({ contextUsageDisplay: value })}
>
{t(labelKey)}
</button>
))}
</div>
</SettingsRow>
);
}

function LargePasteThresholdRow({
settings,
saveSettings,
Expand Down Expand Up @@ -1425,6 +1473,10 @@ export function SettingsPage() {
</SettingsRow>
<CommandShellRow settings={settings} saveSettings={saveSettings} />
<LinkOpenTargetRow settings={settings} saveSettings={saveSettings} />
<ContextUsageDisplayRow
settings={settings}
saveSettings={saveSettings}
/>
<SettingsRow
title={t("settings.enterToSend")}
description={t("settings.enterToSendDesc")}
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/test/context-usage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
calculateTokenRate,
calculateContextUsage,
contextOccupancyTokens,
contextUsageView,
estimateResponseOutputTokens,
estimateToolTokenUsage,
resolveContextUsageDisplay,
resolveContextWindow,
toolTokenUsage,
usageTokenTotal,
Expand All @@ -31,6 +33,31 @@ test("context usage exposes the remaining ring percentage", () => {
assert.equal(context.remainingRatio, 28 / 128);
});

test("context usage display preference picks the ring's leading figure", () => {
const context = calculateContextUsage(
{ inputTokens: 80, outputTokens: 20, totalTokens: 100 },
128,
);

const remaining = contextUsageView(context, "remaining");
assert.equal(remaining.percent, 22);
assert.equal(remaining.tokens, 28);
assert.equal(remaining.ratio, 28 / 128);

const used = contextUsageView(context, "used");
assert.equal(used.percent, 78);
assert.equal(used.tokens, 100);
assert.equal(used.ratio, 100 / 128);
});

test("an absent or unrecognised display value keeps the remaining default", () => {
assert.equal(resolveContextUsageDisplay(undefined), "remaining");
assert.equal(resolveContextUsageDisplay("used"), "used");
assert.equal(resolveContextUsageDisplay("remaining"), "remaining");
assert.equal(resolveContextUsageDisplay("bogus"), "remaining");
assert.equal(resolveContextUsageDisplay(null), "remaining");
});

test("context window prefers the selected model catalog over provider fallback", () => {
const providerModels = {
provider: [
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/test/settings-general.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,23 @@ test("Basics and AI tabs expose their respective app and AI controls", () => {
assert.match(aiSource, /CommandShellRow/);
assert.match(aiSource, /enterToSend: !settings\.enterToSend/);
assert.match(aiSource, /LargePasteThresholdRow/);
assert.match(aiSource, /ContextUsageDisplayRow/);
assert.match(
settingsPageSource,
/saveSettings\(\{ contextUsageDisplay: value \}\)/,
);
for (const key of [
"settings.contextUsageDisplay",
"settings.contextUsageDisplayRemaining",
"settings.contextUsageDisplayUsed",
]) {
assert.match(settingsSearchSource, new RegExp(key.replaceAll(".", "\\.")));
assert.match(enLocaleSource, new RegExp(`${key.split(".").at(-1)}:`));
assert.match(zhLocaleSource, new RegExp(`${key.split(".").at(-1)}:`));
assert.match(trLocaleSource, new RegExp(`${key.split(".").at(-1)}:`));
}
assert.match(sharedTypesSource, /contextUsageDisplay\?: ContextUsageDisplay/);
assert.match(sharedTypesSource, /ContextUsageDisplay = "remaining" \| "used"/);
assert.match(settingsPageSource, /largePasteThreshold/);
assert.match(settingsPageSource, /saveSettings\(\{ largePasteThreshold: next \}\)/);
assert.doesNotMatch(settingsPageSource, /commandShellConfigured/);
Expand Down
65 changes: 65 additions & 0 deletions docs/adr/0223-context-usage-display-preference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ADR 0223: Context Usage Display Preference

- Status: Accepted
- Date: 2026-09-11
- Deciders: PI-Desktop desktop UI maintainers
- Amends: 0184
- Related: [04-ux/06-settings-ia](../spec/04-ux/06-settings-ia.md) ·
[04-ux/08-component-spec](../spec/04-ux/08-component-spec.md) ·
[08-meta/decisions-log](../spec/08-meta/decisions-log.md) (D398) ·
E2E-250

## Context

The composer toolbar's context usage inspector (ADR 0184 / D347) always leads
with the remaining-capacity figure: the trigger ring, popover heading,
tooltip, and `aria-label` all show the remaining token count and percentage.
Some users find the used-capacity figure more intuitive — especially when
context is lightly loaded and the remaining number is close to the total
window, which provides little signal at a glance.

## Decision

1. A new setting `AppSettings.contextUsageDisplay` (`ContextUsageDisplay =
"remaining" | "used"`) lets the user choose which figure the context
inspector leads with. The default (and fallback for absent or
unrecognised values) is `"remaining"`, preserving the existing behaviour.
2. When `contextUsageDisplay` is `"used"`, the composer toolbar ring's
arc length (`strokeDashoffset`), the trigger percentage and token label,
the popover heading, the tooltip, and the `aria-label` all switch to
the used-capacity pair instead of the remaining pair. The ring fills
proportionally to `usedRatio` rather than `remainingRatio`.
3. Warning and critical color thresholds remain based on **remaining**
capacity (remaining ≤ 25 % → warning, ≤ 10 % → critical) regardless
of the display mode. A display reading "used 78 %" still turns warning
colour because only 22 % remains.
4. Settings → AI → Defaults gains a `ContextUsageDisplayRow` (segmented
control: Remaining / Used) placed after the Link open destination row
and before the Enter-to-send row.
5. The change is renderer-only: no protocol, storage schema, host-side
migration, or IPC change. The host-core settings merge preserves
unknown keys, so persisted `contextUsageDisplay` values survive across
upgrades without a schema bump.

## Consequences

- Users who prefer a "how much have I spent" mental model get a consistent
display; users who prefer the original "how much is left" model see no
change by default.
- The ring arc direction flips visually when switching to `"used"`, which
is the correct correspondence: a fuller ring means more context consumed.
- Color semantics stay stable across modes, so the warning/critical signal
is never ambiguous regardless of the chosen display direction.
- No host or storage change means no migration risk and no protocol version
bump.

## Rejected alternatives

- **Boolean toggle (show-used: true/false):** a two-value segmented control
reads clearer than a checkbox for mutually exclusive display modes, and
the `ContextUsageDisplay` union type leaves room for future modes without
a type rename.
- **Color thresholds also follow display mode:** rejected; it would make a
"used 90 %" ring green despite only 10 % remaining, which is dangerously
misleading. Remaining capacity is the safety signal and must stay
authoritative for color.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,4 @@ Each ADR includes:
| 0220 | Keep Windows work-panel chrome single-purpose | Accepted (amends D154 / D357 / ADR 0195) |
| 0221 | Render canonical thinking-level values without translation | Accepted (amends D369 / ADR 0202) |
| 0222 | Native file and folder drops in the Composer | Accepted (amends ADR 0101 / D397) |
| 0223 | Context Usage Display Preference | Accepted (amends 0184) |
15 changes: 9 additions & 6 deletions docs/spec/04-ux/06-settings-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,15 @@ Settings is a **full-window page** that replaces the app sidebar + main chrome (
- **Permissions** card: the global permission-mode control
(ask / accept-edits / auto) that governs how autonomously the agent acts.
- **Defaults** card: the host-backed default operating mode (Agent / Plan / Goal),
command shell selection, Link open destination, Enter-to-send control, and the
large text paste threshold. Link open destination uses the Work panel browser
by default and can route plain HTTP(S) link clicks to the system browser.
The threshold controls when a text-only paste becomes a temporary
session-scratch file; it defaults to 600 characters and accepts integer values
from 1 through 1,000,000.
command shell selection, Link open destination, context usage display
(remaining or used), Enter-to-send control, and the large text paste
threshold. Link open destination uses the Work panel browser by default
and can route plain HTTP(S) link clicks to the system browser. Context
usage display controls whether the composer toolbar context ring and its
popover lead with the remaining or the used capacity figure; the default
is remaining. The threshold controls when a text-only paste becomes a
temporary session-scratch file; it defaults to 600 characters and accepts
integer values from 1 through 1,000,000.
- The **Command shell** row in Defaults uses the host-discovered catalog of native
PowerShell 5.1, PowerShell 7, cmd, Git Bash, and Bash with IDs
`windows-powershell`, `windows-pwsh`, `cmd`, `git-bash`, and
Expand Down
Loading
Loading