Skip to content

Commit 4df8cc6

Browse files
authored
Merge pull request #257 from httpsphl/feat/codex-plan-meter
feat(agent): Codex's plan usage in the usage dialog
2 parents a483c0a + 6a57d6f commit 4df8cc6

18 files changed

Lines changed: 376 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Claude Code is the full experience. Antigravity and Codex are off until you turn
121121
Antigravity and Codex" in Settings (or list them in Botloft's `config.toml`, `experimental_agents =
122122
["agy"]`, `["codex"]` or both); then a new agent can be made on them, and Settings has "New agents run
123123
on". A Codex agent asks you before it changes a file or runs a
124-
command, like Claude Code, and shows the exact size of its conversation; it can still read any file.
124+
command, like Claude Code, and shows the exact size of its conversation and the use of its plan; it can still read any file.
125125
It has its own model list and effort picker, and understands images. See [spec section 30](docs/spec.md) for what was
126126
checked.
127127

‎app/src/features/account/UsageDialog.tsx‎

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,72 @@
33

44
import { useT } from "../../i18n";
55
import { fromNow } from "../../lib/format";
6-
import type { UsageWindow } from "../../lib/protocol.gen";
6+
import type { AgentUsage, UsageWindow } from "../../lib/protocol.gen";
77
import { useApp } from "../../store/context";
88
import { Callout } from "../../ui/Callout";
99
import { Dialog } from "../../ui/Dialog";
1010
import { TokensByBot } from "./TokensByBot";
1111

1212
export function UsageDialog({ onClose }: { onClose(): void }) {
13-
const u = useT().account.usage;
14-
const usage = useApp((state) => state.system?.usage ?? null);
15-
const limited =
16-
usage !== null && usage.status !== "allowed" && usage.status !== "allowed_warning";
13+
const t = useT();
14+
const u = t.account.usage;
15+
const claude = useApp((state) => state.system?.usage ?? null);
16+
const others = useApp((state) => state.system?.agentUsage ?? NO_USAGE);
17+
const names = t.bots.dialog.agent.names;
18+
const named = others.length > 0;
19+
const shown = [
20+
...(claude === null ? [] : [{ agent: "claude" as const, plan: null, usage: claude }]),
21+
...others,
22+
].filter((entry) => entry.usage.windows.length > 0);
1723

1824
return (
1925
<Dialog title={u.title} onClose={onClose}>
2026
<div className="flex flex-col gap-4 text-sm">
2127
<p className="text-ink-soft leading-relaxed">{u.intro}</p>
22-
{usage === null || usage.windows.length === 0 ? (
28+
{shown.length === 0 ? (
2329
<p className="text-muted">{u.empty}</p>
2430
) : (
25-
<>
26-
{limited && <Callout tone="warn" title={u.limited} />}
27-
<ul className="flex flex-col gap-4">
28-
{usage.windows.map((window) => (
29-
<Window key={window.name} window={window} />
30-
))}
31-
</ul>
32-
<p className="text-muted text-xs">{u.updated(fromNow(usage.observedAt))}</p>
33-
</>
31+
shown.map((entry) => {
32+
const limited =
33+
entry.usage.status !== "allowed" && entry.usage.status !== "allowed_warning";
34+
return (
35+
<section key={entry.agent} className="flex flex-col gap-4">
36+
{named && (
37+
<h3 className="font-medium">
38+
{names[entry.agent]}
39+
{entry.plan !== null && (
40+
<span className="ml-2 font-normal text-muted text-xs">
41+
{u.plan(entry.plan)}
42+
</span>
43+
)}
44+
</h3>
45+
)}
46+
{limited && (
47+
<Callout
48+
tone="warn"
49+
title={
50+
entry.agent === "claude" ? u.limited : u.limitedAgent(names[entry.agent])
51+
}
52+
/>
53+
)}
54+
<ul className="flex flex-col gap-4">
55+
{entry.usage.windows.map((window) => (
56+
<Window key={window.name} window={window} />
57+
))}
58+
</ul>
59+
<p className="text-muted text-xs">{u.updated(fromNow(entry.usage.observedAt))}</p>
60+
</section>
61+
);
62+
})
3463
)}
3564
<TokensByBot />
3665
</div>
3766
</Dialog>
3867
);
3968
}
4069

70+
const NO_USAGE: AgentUsage[] = [];
71+
4172
function Window({ window }: { window: UsageWindow }) {
4273
const u = useT().account.usage;
4374
const percent = Math.round(Math.min(1, Math.max(0, window.utilization)) * 100);

‎app/src/features/account/account.test.tsx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,44 @@ describe("account area", () => {
8181
expect(dialog.textContent).toContain("92% used");
8282
});
8383

84+
test("usage of another agent comes under its own name, with its plan and its limit", async () => {
85+
const fake = new FakeBotloft();
86+
const now = Date.now();
87+
fake.system = {
88+
...fake.system,
89+
usage: {
90+
status: "allowed",
91+
resetsAt: null,
92+
observedAt: now,
93+
windows: [{ name: "five_hour", utilization: 0.2, resetsAt: null }],
94+
},
95+
agentUsage: [
96+
{
97+
agent: "codex",
98+
plan: "free",
99+
usage: {
100+
status: "rejected",
101+
resetsAt: now + 3 * 86_400_000,
102+
observedAt: now,
103+
windows: [{ name: "thirty_day", utilization: 1, resetsAt: now + 3 * 86_400_000 }],
104+
},
105+
},
106+
],
107+
};
108+
renderApp(fake);
109+
await openAccount();
110+
fireEvent.click(screen.getByRole("menuitem", { name: "Usage" }));
111+
const dialog = screen.getByRole("dialog", { name: "Usage" });
112+
expect(within(dialog).getByRole("heading", { name: /^Claude Code/ })).toBeDefined();
113+
expect(within(dialog).getByRole("heading", { name: /^Codex/ })).toBeDefined();
114+
expect(dialog.textContent).toContain("Plan: free");
115+
expect(within(dialog).getByRole("progressbar", { name: "30-day window" })).toBeDefined();
116+
expect(dialog.textContent).toContain("100% used");
117+
expect(dialog.textContent).toContain("Agents on Codex wait until it resets.");
118+
// Claude Code is not limited: its own warning does not show.
119+
expect(dialog.textContent).not.toContain("Your agents wait until it resets.");
120+
});
121+
84122
test("usage waits for an agent's first reply", async () => {
85123
renderApp();
86124
await openAccount();

‎app/src/i18n/en/account.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ export const account = {
2323
seven_day: "This week",
2424
seven_day_opus: "This week, Opus",
2525
seven_day_sonnet: "This week, Sonnet",
26+
thirty_day: "30-day window",
2627
} as Record<string, string>,
2728
used: (percent: number) => `${percent}% used`,
2829
resets: (relative: string) => `Resets ${relative}`,
2930
limited: "Limit reached. Your agents wait until it resets.",
31+
limitedAgent: (agent: string) => `Limit reached. Agents on ${agent} wait until it resets.`,
32+
plan: (plan: string) => `Plan: ${plan}`,
3033
updated: (relative: string) => `Updated ${relative}`,
3134
tokens: {
3235
title: "Use by agent",

‎app/src/i18n/es/account.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ export const account: Messages["account"] = {
2222
seven_day: "Esta semana",
2323
seven_day_opus: "Esta semana, Opus",
2424
seven_day_sonnet: "Esta semana, Sonnet",
25+
thirty_day: "Ventana de 30 días",
2526
},
2627
used: (percent: number) => `${percent}% usado`,
2728
resets: (relative: string) => `Se renueva ${relative}`,
2829
limited: "Límite alcanzado. Tus agentes esperan hasta que se renueve.",
30+
limitedAgent: (agent: string) =>
31+
`Límite alcanzado. Los agentes en ${agent} esperan hasta que se renueve.`,
32+
plan: (plan: string) => `Plan: ${plan}`,
2933
updated: (relative: string) => `Actualizado ${relative}`,
3034
tokens: {
3135
title: "Uso por agente",

‎app/src/i18n/pt-BR/account.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ export const account: Messages["account"] = {
2222
seven_day: "Esta semana",
2323
seven_day_opus: "Esta semana, Opus",
2424
seven_day_sonnet: "Esta semana, Sonnet",
25+
thirty_day: "Janela de 30 dias",
2526
},
2627
used: (percent: number) => `${percent}% usado`,
2728
resets: (relative: string) => `Renova ${relative}`,
2829
limited: "Limite atingido. Seus agentes esperam até ele renovar.",
30+
limitedAgent: (agent: string) =>
31+
`Limite atingido. Os agentes no ${agent} esperam até ele renovar.`,
32+
plan: (plan: string) => `Plano: ${plan}`,
2933
updated: (relative: string) => `Atualizado ${relative}`,
3034
tokens: {
3135
title: "Uso por agente",

‎app/src/lib/fake.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export class FakeBotloft implements BotloftApi {
7171
usage: null,
7272
enabledAgents: ["claude"],
7373
agentChecks: [],
74+
agentUsage: [],
7475
};
7576
/** How many times the app asked for a new Claude Code check. */
7677
refreshes = 0;

‎app/src/lib/protocol.gen.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,12 @@ enabledAgents: Array<AgentKind>,
418418
* Whether each enabled agent other than Claude Code is installed
419419
* (spec 30). Claude Code has its own fields above.
420420
*/
421-
agentChecks: Array<AgentCheck>, };
421+
agentChecks: Array<AgentCheck>,
422+
/**
423+
* The account usage of the agents other than Claude Code, as each last
424+
* reported it (spec 30); `usage` above is Claude's.
425+
*/
426+
agentUsage: Array<AgentUsage>, };
422427

423428
export type CrewsCreateParams = { name: string,
424429
/**
@@ -529,6 +534,15 @@ export type AgentCheck = { agent: AgentKind,
529534
*/
530535
version: string | null, };
531536

537+
/**
538+
* The usage of an agent's account, with the plan it is on.
539+
*/
540+
export type AgentUsage = { agent: AgentKind,
541+
/**
542+
* The plan the agent names (`free`, `plus`...); `null` when it does not.
543+
*/
544+
plan: string | null, usage: AccountUsage, };
545+
532546
export type BotIdParams = { botId: BotId, };
533547

534548
export type BotsRestartParams = { botId: BotId,

‎crates/botloft-core/src/protocol/export.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ fn export_bindings() {
161161
out.decl::<AgentsModelsParams>();
162162
out.decl::<AgentModel>();
163163
out.decl::<AgentCheck>();
164+
out.decl::<AgentUsage>();
164165
out.decl::<BotIdParams>();
165166
out.decl::<BotsRestartParams>();
166167
out.decl::<BotStateChanged>();

‎crates/botloft-core/src/protocol/methods.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ pub struct SystemStatus {
8484
/// Whether each enabled agent other than Claude Code is installed
8585
/// (spec 30). Claude Code has its own fields above.
8686
pub agent_checks: Vec<AgentCheck>,
87+
/// The account usage of the agents other than Claude Code, as each last
88+
/// reported it (spec 30); `usage` above is Claude's.
89+
pub agent_usage: Vec<AgentUsage>,
90+
}
91+
92+
/// The usage of an agent's account, with the plan it is on.
93+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94+
#[serde(rename_all = "camelCase")]
95+
#[cfg_attr(test, derive(ts_rs::TS))]
96+
pub struct AgentUsage {
97+
pub agent: AgentKind,
98+
/// The plan the agent names (`free`, `plus`...); `null` when it does not.
99+
pub plan: Option<String>,
100+
pub usage: AccountUsage,
87101
}
88102

89103
/// What looking for an agent's program found.

0 commit comments

Comments
 (0)