feat(agents): show Claude usage limits and session cost in the agent task header (#1041)

Claude Code reports the account's 5-hour and weekly usage limits (rate_limit_event) and the session cost (result.total_cost_usd); only the limit status was used. The limits now show as meters next to the context meter in the task header, with the reset time in the tooltip, and Settings › Tools › Agents › Chat turns each meter (context, work time, limits, cost) on or off. Limits are per provider and kept in memory; cost is off by default.

Closes #1057

Co-authored-by: matthiasdemauroy <[email protected]>
Цей коміт міститься в:
matthiasdemauroyтаmatthiasdemauroy authored and GitHub committed 2026-11-04 00:28:23 +08:00
1 джерело 4eda613c59
коміт 39fe96bd01
19 files changed
+459 -16

No files matched your search

+36
Переглянути файл
@@ -1161,6 +1161,12 @@ export type AgentConfigVia =
/** `session/set_model` (agents that report `models` instead of a model config option). */
"model";
export type AgentCost = {
amount: number | null,
/** ISO 4217 code, e.g. `USD`. */
currency: string,
};
/** One item of a task's conversation. */
export type AgentEntry = { type: "user"; text: string;
/** Names of the files and images sent with it. */
@@ -1206,6 +1212,8 @@ export type AgentEvent =
{ type: "truncated"; task: string; length: number } |
/** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */
{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry } |
/** An agent reported the usage limits of its account (shared by every task of `provider`). */
{ type: "limits"; provider: string; limits: AgentLimit[] } |
/** The installed or configured agent providers changed. */
{ type: "providers" };
@@ -1216,6 +1224,16 @@ export type AgentImage = {
name: string | null,
};
/** One usage-limit window of an agent's account. */
export type AgentLimit = {
/** The window as the agent names it: `five_hour`, `seven_day`, `seven_day_opus`… */
window: string,
/** Share of the window used, 0–100. */
usedPercent: number | null,
/** When the window resets (ms since the epoch), if the agent said. */
resetsMs: number | null,
};
export type AgentPermissionOption = {
id: string,
name: string,
@@ -1297,6 +1315,18 @@ export type AgentRetry = {
error: string | null,
};
/** What the meters in an agent task's header show. */
export type AgentSettings = {
/** Context window use. */
showContextUsage?: boolean,
/** Time the agent spent on the task. */
showWorkTime?: boolean,
/** The account's usage-limit windows (Claude's 5-hour and weekly limits). */
showUsageLimits?: boolean,
/** What the session has cost so far, as the agent reports it. */
showCost?: boolean,
};
/** Where an agent comes from. */
export type AgentSource = { kind: "builtin" } |
/** Defined by the user in `<config>/agents/<id>.toml`. */
@@ -1378,6 +1408,8 @@ export type AgentUsage = {
used: number,
/** Size of the context window. */
size: number,
/** What the session has cost so far (ACP `usage_update.cost`), when the agent reports it. */
cost?: AgentCost | null,
};
export type AppInfo = {
@@ -5785,6 +5817,8 @@ export type Settings_Deserialize = {
/** Build, Execution, Deployment › Run Targets. */
runTargets?: RunTarget[],
cmake?: CMakeSettings_Deserialize,
/** Tools › Agents › Chat. */
agents?: AgentSettings,
};
export type Settings_Serialize = {
@@ -5841,6 +5875,8 @@ export type Settings_Serialize = {
/** Build, Execution, Deployment › Run Targets. */
runTargets: RunTarget[],
cmake: CMakeSettings_Serialize,
/** Tools › Agents › Chat. */
agents: AgentSettings,
};
export type Severity = "error" | "warning" | "info" | "hint";
+29 -5
Переглянути файл
@@ -2,7 +2,7 @@
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import type { AgentChange, AgentEntry, AgentImage, AgentRetry, AgentTask, AgentUsage } from "@/bindings";
import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, AgentTask, AgentUsage } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { basename, relativeDir, useProject } from "@/platform/project";
@@ -28,7 +28,7 @@ import { isImeKey } from "@/platform/ime";
import { TaskStatusIcon } from "./AgentsView";
import { Composer } from "./Composer";
import { SubmitButton } from "./SubmitButton";
import { canSend, emptyDraft, retryParts, usageLabel, workTime } from "./composerModel";
import { canSend, costLabel, emptyDraft, limitLabel, retryParts, usageLabel, workTime } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
import { findRanges, formatDuration, formatSentAt } from "./conversation";
import { ImagePreview, imageSrc } from "./ImagePreview";
@@ -287,21 +287,45 @@ function Header({
);
}
/** Context use (when the agent reports it) and the time spent on the task. */
/**
* Context use, usage limits and cost (when the agent reports them) and the time spent on
* the task, each as Settings › Tools › Agents › Chat allows.
*/
function Meters({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
const tx = useTx();
const show = useProject((s) => s.settings?.agents);
const busy = isBusy(task.status);
const now = useNow(busy ? 1000 : 60_000);
const usage = task.usage;
const work = entries ? workTime(entries, busy ? now : null) : 0;
const usage = show?.showContextUsage !== false ? task.usage : null;
const reported = useAgents((s) => s.limits.get(task.provider));
const limits = show?.showUsageLimits !== false ? (reported ?? []) : [];
const cost = show?.showCost === true ? task.usage?.cost : null;
const work = show?.showWorkTime !== false && entries ? workTime(entries, busy ? now : null) : 0;
return (
<span className={styles.meters}>
{usage && <Usage usage={usage} />}
{limits.map((l) => (
<Limit key={l.window} limit={l} now={now} />
))}
{cost && <span title={tx("What the session has cost so far, as the agent reports it")}>{costLabel(cost)}</span>}
{work >= 1000 && <span title={tx("Time the agent spent on this task")}>{formatDuration(work)}</span>}
</span>
);
}
/** A usage-limit window: "5h 19%", when it resets in the tooltip. */
function Limit({ limit, now }: { limit: AgentLimit; now: number }) {
const label = limitLabel(limit, now);
return (
<span className={styles.meter} title={label.detail} aria-label={label.detail}>
<span className={styles.usageBar} aria-hidden>
<span style={{ width: `${label.percent}%` }} />
</span>
{label.short}
</span>
);
}
/** Context window use: "12%", the numbers in the tooltip. */
function Usage({ usage }: { usage: AgentUsage }) {
const label = usageLabel(usage);
+24
Переглянути файл
@@ -17,6 +17,8 @@ import {
toPrompt,
retryParts,
usageLabel,
limitLabel,
costLabel,
workTime,
} from "./composerModel";
import {
@@ -151,6 +153,28 @@ describe("usage meters", () => {
expect(usageLabel({ used: 83_100, size: 1_000_000 }).detail).toBe("83.1k / 1M tokens");
});
it("names usage-limit windows and says when they reset", () => {
const hour = 3_600_000;
expect(limitLabel({ window: "five_hour", usedPercent: 19.4, resetsMs: 2 * hour + 51 * 60_000 }, 0)).toEqual({
short: "5h 19%",
percent: 19,
detail: "5-hour limit: 19% used, resets in 2h 51m",
});
expect(limitLabel({ window: "seven_day", usedPercent: 120, resetsMs: 5 * 24 * hour + 3 * hour }, 0).detail).toBe(
"Weekly limit: 100% used, resets in 5d 3h",
);
expect(limitLabel({ window: "spend_limit", usedPercent: 7, resetsMs: null }, 0)).toEqual({
short: "spend_limit 7%",
percent: 7,
detail: "spend_limit: 7% used",
});
});
it("formats the session cost", () => {
expect(costLabel({ amount: 0.4231, currency: "USD" })).toBe("$0.42");
expect(costLabel({ amount: 1.5, currency: "credits" })).toBe("1.50 credits");
});
it("adds up the time spent on turns", () => {
const user = (sentMs: number, doneMs: number | null): AgentEntry => ({
type: "user",
+40
Переглянути файл
@@ -2,8 +2,10 @@
import type {
AgentCommand,
AgentConfigOption,
AgentCost,
AgentEntry,
AgentImage,
AgentLimit,
AgentPrompt,
AgentRetry,
AgentUsage,
@@ -181,6 +183,44 @@ export function usageLabel(u: AgentUsage): { short: string; detail: string } {
return { short: `${pct}%`, detail: tx`${tokens(u.used)} / ${tokens(u.size)} tokens` };
}
/** The usage-limit windows agents report, by the name they give them; others show that name. */
const LIMIT_WINDOWS: Record<string, { short: string; name: string }> = {
five_hour: { short: "5h", name: "5-hour limit" },
seven_day: { short: "7d", name: "Weekly limit" },
seven_day_opus: { short: "7d Opus", name: "Weekly Opus limit" },
seven_day_sonnet: { short: "7d Sonnet", name: "Weekly Sonnet limit" },
};
/** "5h 19%" for a usage-limit window, and a tooltip saying when it resets. */
export function limitLabel(l: AgentLimit, now: number): { short: string; percent: number; detail: string } {
const known = LIMIT_WINDOWS[l.window];
const percent = Math.min(100, Math.round(l.usedPercent ?? 0));
const name = known ? tx(known.name) : l.window;
const used = tx`${name}: ${percent}% used`;
if (l.resetsMs == null) return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: used };
const reset = untilReset(Math.max(0, l.resetsMs - now));
return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: tx`${used}, resets in ${reset}` };
}
/** `45m`, `2h 51m`, `5d 3h`. */
function untilReset(ms: number): string {
const m = Math.round(ms / 60_000);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ${String(m % 60).padStart(2, "0")}m`;
return `${Math.floor(h / 24)}d ${h % 24}h`;
}
/** "$0.42": what the session has cost so far. */
export function costLabel(c: AgentCost): string {
try {
return new Intl.NumberFormat("en-US", { style: "currency", currency: c.currency }).format(c.amount ?? 0);
} catch {
// Not an ISO 4217 code.
return `${(c.amount ?? 0).toFixed(2)} ${c.currency}`;
}
}
/** `850`, `83.1k`, `1M`. */
function tokens(n: number): string {
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
+34
Переглянути файл
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import { AgentsView } from "./AgentsView";
import { SessionEditor } from "./SessionEditor";
@@ -227,3 +228,36 @@ describe("session state across tasks and hosts", () => {
expect(useAgents.getState().scrollPositions.size).toBe(0);
});
});
describe("meters in the task header", () => {
afterEach(() => {
useProject.setState({ settings: null });
useAgents.setState({ limits: new Map() });
});
it("shows the provider's usage limits by default and each meter as Settings › Tools › Agents › Chat allows", async () => {
const reported: AgentTask = {
...task("A"),
usage: { used: 24_000, size: 200_000, cost: { amount: 0.42, currency: "USD" } },
};
const limits = [
{ window: "five_hour", usedPercent: 19, resetsMs: null },
{ window: "seven_day", usedPercent: 9.4, resetsMs: null },
];
useAgents.setState({ tasks: new Map([["A", reported]]), limits: new Map([["codex", limits]]) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Answer A");
expect(screen.getByText("12%")).toBeInTheDocument();
expect(screen.getByText("5h 19%")).toBeInTheDocument();
expect(screen.getByText("7d 9%")).toBeInTheDocument();
expect(screen.queryByText("$0.42")).toBeNull();
act(() =>
useProject.setState({
settings: { agents: { showContextUsage: false, showUsageLimits: false, showCost: true } },
}),
);
expect(screen.queryByText("12%")).toBeNull();
expect(screen.queryByText("5h 19%")).toBeNull();
expect(screen.getByText("$0.42")).toBeInTheDocument();
});
});
+8
Переглянути файл
@@ -8,6 +8,7 @@ import type {
AgentEntry,
AgentEvent,
AgentImage,
AgentLimit,
AgentProvider,
AgentTask,
Isolation,
@@ -59,6 +60,8 @@ interface AgentsState {
/** Scroll position and follow-output preference by conversation, shared by both hosts. */
scrollPositions: Map<string, { top: number; stick: boolean }>;
providers: AgentProvider[];
/** The usage limits each agent last reported for its account, by provider. */
limits: Map<string, AgentLimit[]>;
draft: Draft;
composers: Map<string, ComposerDraft>;
/** The composer typed in last: where "Send to Agent" adds references. */
@@ -106,6 +109,7 @@ export const useAgents = create<AgentsState>(() => ({
entries: new Map(),
changes: new Map(),
providers: [],
limits: new Map(),
draft: { ...fallbackDraft, ...load<Partial<Draft>>(DRAFT_KEY, {}) },
composers: new Map(),
lastComposer: HOME,
@@ -261,6 +265,10 @@ function onEvent(e: AgentEvent) {
void refreshProviders();
break;
}
case "limits": {
useAgents.setState({ limits: new Map(s.limits).set(e.provider, e.limits) });
break;
}
case "entry": {
if (
!e.subagent &&
+10
Переглянути файл
@@ -132,6 +132,16 @@ describe("agent unread activity", () => {
expect(backend.notify).toHaveBeenCalledTimes(1);
});
it("keeps the latest usage limits per provider without marking anything unread", () => {
const fiveHour = (usedPercent: number) => [{ window: "five_hour", usedPercent, resetsMs: null }];
emit({ type: "limits", provider: "claude", limits: fiveHour(10) });
emit({ type: "limits", provider: "codex", limits: fiveHour(50) });
emit({ type: "limits", provider: "claude", limits: fiveHour(12) });
expect(useAgents.getState().limits.get("claude")).toEqual(fiveHour(12));
expect(useAgents.getState().limits.get("codex")).toEqual(fiveHour(50));
expect(useAgents.getState().unread.size).toBe(0);
});
it("ignores user messages, thoughts, empty replies and subagent streaming", () => {
const entries: AgentEntry[] = [
{ type: "user", text: "Do this", attachments: [] },
+15
Переглянути файл
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { validateSettings } from "../model/schema";
describe("agent chat settings", () => {
it("defaults every meter but the cost to on and keeps stored values", () => {
expect(validateSettings({}).agents).toEqual({
showContextUsage: true,
showWorkTime: true,
showUsageLimits: true,
showCost: false,
});
const stored = validateSettings({ agents: { showUsageLimits: false, showCost: true, showWorkTime: "no" } }).agents;
expect(stored).toEqual({ showContextUsage: true, showWorkTime: true, showUsageLimits: false, showCost: true });
});
});
+11 -1
Переглянути файл
@@ -7,7 +7,7 @@
import { validateCMake } from "@/platform/cmakeSettings";
import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames";
import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes";
import type { LanguageServerSettings, Settings } from "@/platform/ipc";
import type { AgentSettings, LanguageServerSettings, Settings } from "@/platform/ipc";
import {
DEFAULT_LANGUAGES,
DEFAULT_PROJECT_STRUCTURE,
@@ -92,6 +92,8 @@ export interface ValidSettings extends WorkflowSettings {
tools: Record<string, { path: string }>;
/** Settings › Language Servers, by server name (as the backend sends them). */
languageServers: Record<string, LanguageServerEntry>;
/** Tools › Agents › Chat: the meters in an agent task's header. */
agents: Required<AgentSettings>;
}
/** One `languageServers.<name>` entry; every field optional, like in settings.toml. */
@@ -158,6 +160,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
spelling: { ideWords: [], projectWords: [], customDictionaries: [] },
tools: {},
languageServers: {},
agents: { showContextUsage: true, showWorkTime: true, showUsageLimits: true, showCost: false },
...DEFAULT_WORKFLOW,
};
@@ -201,6 +204,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
const a = obj(root.appearance);
const e = obj(root.editor);
const f = obj(root.files);
const agents = obj(root.agents);
const overrides = bindingMap(root.keymapOverrides);
const customKeymaps: Record<string, CustomKeymapSettings> = {};
for (const [id, raw] of Object.entries(obj(root.customKeymaps))) {
@@ -270,6 +274,12 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
languageServers: Object.fromEntries(
Object.entries(obj(root.languageServers)).map(([name, ls]) => [name, obj(ls) as LanguageServerEntry]),
),
agents: {
showContextUsage: bool(agents.showContextUsage, d.agents.showContextUsage),
showWorkTime: bool(agents.showWorkTime, d.agents.showWorkTime),
showUsageLimits: bool(agents.showUsageLimits, d.agents.showUsageLimits),
showCost: bool(agents.showCost, d.agents.showCost),
},
...validateWorkflow(root),
};
}
+32 -3
Переглянути файл
@@ -1,7 +1,7 @@
/**
* Tools pages: Terminal, Actions on Save and External Tools. Consumed by the
* terminal feature, the editor save participants and the run feature
* (External Tools actions).
* Tools pages: Terminal, Actions on Save, External Tools and Agents › Chat. Consumed
* by the terminal feature, the editor save participants, the run feature
* (External Tools actions) and the agent task editor.
*/
import { FontPicker } from "../components/FontPicker";
@@ -623,6 +623,35 @@ export const TOOLS_PAGES: PageSpec[] = [
{ options: [{ kind: "custom", label: "Save", component: ActionsOnSaveNote }] },
],
},
{
// Under Settings › Tools › Agents (the agents feature registers `tools.agents`).
id: "tools.agents.chat",
title: "Chat",
group: TOOLS_GROUP,
order: 0,
keywords: ["agent", "claude", "codex", "usage", "quota", "rate limit", "cost", "tokens", "meter"],
sections: [
{
title: "Shown in the Task Header",
options: [
{ kind: "toggle", key: "agents.showContextUsage", label: "Context window usage" },
{ kind: "toggle", key: "agents.showWorkTime", label: "Time spent on the task" },
{
kind: "toggle",
key: "agents.showUsageLimits",
label: "Usage limits",
hint: "The account's 5-hour and weekly limits, when the agent reports them (Claude Code)",
},
{
kind: "toggle",
key: "agents.showCost",
label: "Session cost",
hint: "What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)",
},
],
},
],
},
{
id: "tools.externalTools",
title: "External Tools",
+16 -1
Переглянути файл
@@ -5688,5 +5688,20 @@
"Unresolved merge conflicts": "未解决的合并冲突",
"Reports the <<<<<<<, ======= and >>>>>>> lines a merge leaves in a file, whatever its language and whether or not a language server is installed.": "报告合并在文件中留下的 <<<<<<<、======= 和 >>>>>>> 行,与文件语言以及是否安装了语言服务器无关。",
"Merge/Rebase Widget": "合并/变基微件",
"Unresolved merge conflict markers": "未解决的合并冲突标记"
"Unresolved merge conflict markers": "未解决的合并冲突标记",
"What the session has cost so far, as the agent reports it": "智能体报告的本会话目前花费",
"{name}: {percent}% used": "{name}:已使用 {percent}%",
"{used}, resets in {reset}": "{used},{reset} 后重置",
"5-hour limit": "5 小时限额",
"Weekly limit": "每周限额",
"Weekly Opus limit": "每周 Opus 限额",
"Weekly Sonnet limit": "每周 Sonnet 限额",
"Chat": "聊天",
"Shown in the Task Header": "在任务标题栏中显示",
"Context window usage": "上下文窗口用量",
"Time spent on the task": "任务耗时",
"Usage limits": "用量限额",
"The account's 5-hour and weekly limits, when the agent reports them (Claude Code)": "账户的 5 小时和每周限额,在智能体报告时显示(Claude Code)",
"Session cost": "会话花费",
"What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)"
}