feat(coding-agent): refactored usage command into show/reset subcommands

- Added `handleUsageResetCommand` support to list and redeem usage reset credits.
- Refactored `/usage` into `show` and `reset` subcommands and removed `/reset-usage`.
- Handled ACP/TUI `/usage` flows so `show` reports usage and `reset` redeems credits.
- Kept selected theme setting values dirty-colored while selected in the UI list.
This commit is contained in:
can1357
2026-06-12 06:14:18 +02:00
parent f80cf7d584
commit 35d3afc929
12 changed files with 192 additions and 73 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
* (`already_redeemed`, `no_credit`, `nothing_to_reset`).
*
* These are thin, dependency-light functions so both the interactive session
* (the `/reset-usage` command + auto-redeem) and any out-of-band tooling can
* (the `/usage reset` command + auto-redeem) and any out-of-band tooling can
* share one wire contract.
*/
import type { FetchImpl } from "../types";
+6
View File
@@ -3,9 +3,15 @@
## [Unreleased]
### Changed
- Renamed the saved Codex reset command from `/reset-usage` to `/usage reset` and added `/usage show` as an explicit alias for plain `/usage`.
### Fixed
- Fixed the Subagents HUD order so progress updates no longer move active rows; batched subagents keep their task order.
- Fixed `/settings` dirty-value highlighting so changed values stay orange while the row has keyboard focus.
## [15.11.5] - 2026-06-12
### Added
@@ -7,7 +7,7 @@ import { DynamicBorder } from "./dynamic-border";
const RESET_SELECTOR_MAX_VISIBLE = 10;
/**
* Account picker for `/reset-usage`. Lists Codex accounts with their saved
* Account picker for `/usage reset`. Lists Codex accounts with their saved
* rate-limit reset counts; selecting one redeems a reset. Because a reset is a
* scarce, irreversible credit, Enter requires a second press to confirm.
*/
@@ -1546,7 +1546,7 @@ function renderUsageReports(
}
if (resetAccountLines.length > 0) {
lines.push(
` ${uiTheme.fg("accent", "Saved rate-limit resets")} ${uiTheme.fg("dim", "(/reset-usage to spend)")}`,
` ${uiTheme.fg("accent", "Saved rate-limit resets")} ${uiTheme.fg("dim", "(/usage reset to spend)")}`,
);
for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line));
}
@@ -2762,7 +2762,7 @@ export function getSettingsListTheme(): SettingsListTheme {
label: (text: string, selected: boolean, changed: boolean) =>
changed ? theme.fg("statusLineGitDirty", text) : selected ? theme.fg("accent", text) : text,
value: (text: string, selected: boolean, changed: boolean) =>
selected ? theme.fg("accent", text) : changed ? theme.fg("statusLineGitDirty", text) : theme.fg("muted", text),
changed ? theme.fg("statusLineGitDirty", text) : selected ? theme.fg("accent", text) : theme.fg("muted", text),
description: (text: string) => theme.fg("dim", text),
cursor: theme.fg("accent", `${theme.nav.cursor} `),
hint: (text: string) => theme.fg("dim", text),
@@ -10148,7 +10148,7 @@ export class AgentSession {
/**
* Redeem one saved Codex rate-limit reset for a specific account, injecting
* the provider base URL like {@link AgentSession.fetchUsageReports}. Powers
* the `/reset-usage` command and auto-redeem. Never throws for business
* the `/usage reset` command and auto-redeem. Never throws for business
* outcomes — inspect the returned `code`.
*/
async redeemResetCredit(target: ResetCreditTarget, signal?: AbortSignal): Promise<ResetCreditRedeemOutcome> {
@@ -10162,7 +10162,7 @@ export class AgentSession {
/**
* List saved Codex rate-limit resets per stored account, fetched live from
* the dedicated credits endpoint (bypasses the usage cache). Powers the
* `/reset-usage` account selector.
* `/usage reset` account selector.
*/
async listResetCredits(signal?: AbortSignal): Promise<ResetCreditAccountStatus[]> {
return this.#modelRegistry.authStorage.listResetCredits({
@@ -22,7 +22,7 @@ import {
} from "../extensibility/plugins/marketplace";
import { resolveMemoryBackend } from "../memory-backend";
import type { InteractiveModeContext } from "../modes/types";
import type { FreshSessionResult } from "../session/agent-session";
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
import { getChangelogPath, parseChangelog } from "../utils/changelog";
import { buildContextReportText } from "./helpers/context-report";
@@ -30,7 +30,7 @@ import { formatDuration } from "./helpers/format";
import { createMarketplaceManager } from "./helpers/marketplace-manager";
import { handleMcpAcp } from "./helpers/mcp";
import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
import { describeRedeemOutcome, toResetUsageAccounts } from "./helpers/reset-usage";
import { describeRedeemOutcome, type ResetUsageAccount, toResetUsageAccounts } from "./helpers/reset-usage";
import { handleSshAcp } from "./helpers/ssh";
import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard";
import { handleTodoAcp } from "./helpers/todo";
@@ -67,6 +67,55 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
return commandConsumed();
};
async function handleUsageResetCommand(
arg: string,
session: AgentSession,
output: SlashCommandRuntime["output"],
): Promise<void> {
let accounts: ResetUsageAccount[];
try {
accounts = toResetUsageAccounts(await session.listResetCredits());
} catch (error) {
await output(`Could not load saved resets: ${errorMessage(error)}`);
return;
}
if (accounts.length === 0) {
await output("No Codex accounts found. Use /login to add one.");
return;
}
const targetArg = arg.trim();
if (!targetArg) {
const lines = ["Saved Codex rate-limit resets:"];
for (const account of accounts) {
const detail = account.error ? `unavailable (${account.error})` : `${account.availableCount} available`;
lines.push(`- ${account.label}: ${detail}${account.active ? " (active)" : ""}`);
}
lines.push("", "Spend one with `/usage reset <account email>` or `/usage reset active`.");
await output(lines.join("\n"));
return;
}
const wanted = targetArg.toLowerCase();
const target =
wanted === "active"
? accounts.find(account => account.active)
: accounts.find(
account =>
account.label.toLowerCase() === wanted ||
account.target.email?.toLowerCase() === wanted ||
account.target.accountId?.toLowerCase() === wanted,
);
if (!target) {
await output(`No Codex account matches "${targetArg}".`);
return;
}
if (target.availableCount <= 0) {
await output(`${target.label}: no saved resets to spend.`);
return;
}
const outcome = await session.redeemResetCredit(target.target);
await output(describeRedeemOutcome(outcome, target.label));
}
const DEBUG_DUMP_NEXT_REQUEST_USAGE = "Usage: /debug dump-next-request <path>";
function resolveDebugRequestDumpPath(target: string, cwd: string): string {
@@ -593,71 +642,41 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
name: "usage",
description: "Show provider usage and limits",
acpDescription: "Show token usage",
handle: async (_command, runtime) => {
await runtime.output(await buildUsageReportText(runtime));
return commandConsumed();
},
handleTui: async (_command, runtime) => {
await runtime.ctx.handleUsageCommand();
runtime.ctx.editor.setText("");
},
},
{
name: "reset-usage",
description: "Spend a saved Codex rate-limit reset",
acpDescription: "Spend a saved rate-limit reset",
inlineHint: "[account|active]",
acpInputHint: "[show|reset [account|active]]",
subcommands: [
{ name: "show", description: "Show provider usage and limits" },
{ name: "reset", description: "Spend a saved Codex rate-limit reset", usage: "[account|active]" },
],
allowArgs: true,
handle: async (command, runtime) => {
const { session } = runtime;
let accounts: ReturnType<typeof toResetUsageAccounts>;
try {
accounts = toResetUsageAccounts(await session.listResetCredits());
} catch (error) {
await runtime.output(
`Could not load saved resets: ${error instanceof Error ? error.message : String(error)}`,
);
const { verb, rest } = parseSubcommand(command.args);
if (!verb || (verb === "show" && !rest)) {
await runtime.output(await buildUsageReportText(runtime));
return commandConsumed();
}
if (accounts.length === 0) {
await runtime.output("No Codex accounts found. Use /login to add one.");
if (verb === "reset") {
await handleUsageResetCommand(rest, runtime.session, runtime.output);
return commandConsumed();
}
const arg = command.args.trim();
if (!arg) {
const lines = ["Saved Codex rate-limit resets:"];
for (const account of accounts) {
const detail = account.error ? `unavailable (${account.error})` : `${account.availableCount} available`;
lines.push(`- ${account.label}: ${detail}${account.active ? " (active)" : ""}`);
}
lines.push("", "Spend one with `/reset-usage <account email>` or `/reset-usage active`.");
await runtime.output(lines.join("\n"));
return commandConsumed();
}
const wanted = arg.toLowerCase();
const target =
wanted === "active"
? accounts.find(account => account.active)
: accounts.find(
account =>
account.label.toLowerCase() === wanted ||
account.target.email?.toLowerCase() === wanted ||
account.target.accountId?.toLowerCase() === wanted,
);
if (!target) {
await runtime.output(`No Codex account matches "${arg}".`);
return commandConsumed();
}
if (target.availableCount <= 0) {
await runtime.output(`${target.label}: no saved resets to spend.`);
return commandConsumed();
}
const outcome = await session.redeemResetCredit(target.target);
await runtime.output(describeRedeemOutcome(outcome, target.label));
return commandConsumed();
return usage("Usage: /usage [show|reset [account|active]]", runtime);
},
handleTui: (_command, runtime) => {
void runtime.ctx.showResetUsageSelector();
handleTui: async (command, runtime) => {
const { verb, rest } = parseSubcommand(command.args);
if (!verb || (verb === "show" && !rest)) {
await runtime.ctx.handleUsageCommand();
runtime.ctx.editor.setText("");
return;
}
if (verb === "reset") {
if (rest) {
await handleUsageResetCommand(rest, runtime.ctx.session, text => runtime.ctx.showStatus(text));
} else {
await runtime.ctx.showResetUsageSelector();
}
runtime.ctx.editor.setText("");
return;
}
runtime.ctx.showStatus("Usage: /usage [show|reset [account|active]]");
runtime.ctx.editor.setText("");
},
},
@@ -1,5 +1,5 @@
/**
* Shared helpers for the `/reset-usage` command (TUI selector + ACP): turn the
* Shared helpers for the `/usage reset` command (TUI selector + ACP): turn the
* live per-account reset-credit status into selector rows, and map a redeem
* outcome code to a human message.
*/
@@ -63,7 +63,7 @@ function renderUsageReports(
? report.metadata.accountId
: "account";
lines.push(
`- ${resetLabel}: ${savedResets} saved rate-limit reset${savedResets === 1 ? "" : "s"} available — /reset-usage to spend`,
`- ${resetLabel}: ${savedResets} saved rate-limit reset${savedResets === 1 ? "" : "s"} available — /usage reset to spend`,
);
}
if (report.limits.length === 0) {
@@ -1,4 +1,10 @@
import { describe, expect, it, spyOn } from "bun:test";
import type {
ResetCreditAccountStatus,
ResetCreditRedeemOutcome,
ResetCreditTarget,
UsageReport,
} from "@oh-my-pi/pi-ai";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import type { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
@@ -35,6 +41,8 @@ interface FakeAcpBuiltinSession {
getContextUsage(): { tokens?: number; contextWindow: number } | undefined;
getAvailableModels(): Array<{ provider: string; id: string; contextWindow?: number }>;
setModel(model: unknown): Promise<void>;
listResetCredits: () => Promise<ResetCreditAccountStatus[]>;
redeemResetCredit: (target: ResetCreditTarget) => Promise<ResetCreditRedeemOutcome>;
}
function createRuntime() {
@@ -60,6 +68,12 @@ function createRuntime() {
setForcedToolChoice(toolName: string) {
this.forcedToolChoice = toolName;
},
async listResetCredits() {
return [];
},
async redeemResetCredit(_target) {
return { ok: false, code: "no_credit" };
},
async newSession(_opts?: { drop?: boolean; parentSession?: string }) {
return true;
},
@@ -198,6 +212,74 @@ describe("ACP builtin slash commands", () => {
expect(output[0]).toContain("user@example.com: 0.24 unknown used (76.0% left)");
expect(output[0]).toContain("resets in");
});
it("/usage show renders the same report as plain /usage", async () => {
const now = 1_700_000_000_000;
const nowSpy = spyOn(Date, "now").mockReturnValue(now);
try {
const reports: UsageReport[] = [
{
provider: "openai-codex",
fetchedAt: now - 5_000,
limits: [
{
id: "codex-5h",
label: "5 hours",
scope: { provider: "openai-codex", tier: "prolite", accountId: "account-1" },
window: { id: "5h", label: "5 hours", resetsAt: now + 60 * 60 * 1000 },
amount: { used: 0.24, usedFraction: 0.24, unit: "unknown" },
},
],
metadata: { email: "user@example.com" },
},
];
const plain = createRuntime();
const show = createRuntime();
plain.runtime.session.fetchUsageReports = async () => reports;
show.runtime.session.fetchUsageReports = async () => reports;
const plainResult = await executeAcpBuiltinSlashCommand("/usage", plain.runtime);
const showResult = await executeAcpBuiltinSlashCommand("/usage show", show.runtime);
expect(showResult).toEqual(plainResult);
expect(show.output).toEqual(plain.output);
} finally {
nowSpy.mockRestore();
}
});
it("routes saved reset redemption through /usage reset", async () => {
const { output, runtime } = createRuntime();
let redeemedTarget: ResetCreditTarget | undefined;
runtime.session.listResetCredits = async () => [
{
credentialId: 42,
accountId: "account-1",
email: "user@example.com",
availableCount: 1,
credits: [],
active: true,
},
];
runtime.session.redeemResetCredit = async target => {
redeemedTarget = target;
return { ok: true, code: "reset", email: target.email };
};
const result = await executeAcpBuiltinSlashCommand("/usage reset active", runtime);
expect(result).toEqual({ consumed: true });
expect(redeemedTarget).toEqual({ credentialId: 42, accountId: "account-1", email: "user@example.com" });
expect(output).toEqual(["Reset applied for user@example.com — your rate-limit window has been refreshed."]);
});
it("does not dispatch the legacy /reset-usage command", async () => {
const { output, runtime } = createRuntime();
const result = await executeAcpBuiltinSlashCommand("/reset-usage active", runtime);
expect(result).toBe(false);
expect(output).toEqual([]);
});
it("returns false for unknown commands", async () => {
const { runtime } = createRuntime();
@@ -35,6 +35,17 @@ describe("buildAvailableSlashCommands", () => {
const commands = await buildAvailableSlashCommands(session as never, async () => fileCommands);
const byName = Object.fromEntries(commands.map(command => [command.name, command]));
expect(byName.usage.subcommands).toContainEqual({
name: "show",
description: "Show provider usage and limits",
});
expect(byName.usage.subcommands).toContainEqual({
name: "reset",
description: "Spend a saved Codex rate-limit reset",
usage: "[account|active]",
});
expect(byName["reset-usage"]).toBeUndefined();
expect(byName.model.source).toBe("builtin");
expect(byName["skill:reviewer"].source).toBe("skill");
expect(byName["ext:hello"].source).toBe("extension");
@@ -6,16 +6,17 @@ beforeAll(async () => {
});
describe("getSettingsListTheme", () => {
it("keeps modified labels dirty while selected values use the cursor accent", () => {
it("keeps modified labels and values dirty even when selected", () => {
const settingsTheme = getSettingsListTheme();
const selectedChangedLabel = settingsTheme.label("Changed", true, true);
const selectedChangedValue = settingsTheme.value("changed", true, true);
const unselectedChangedValue = settingsTheme.value("changed", false, true);
const selectedChangedLabel = settingsTheme.label("Changed", true, true);
const selectedDefaultValue = settingsTheme.value("default", true, false);
expect(selectedChangedValue).toBe(theme.fg("accent", "changed"));
expect(unselectedChangedValue).toBe(theme.fg("statusLineGitDirty", "changed"));
expect(selectedChangedLabel).toBe(theme.fg("statusLineGitDirty", "Changed"));
expect(selectedChangedValue).not.toBe(unselectedChangedValue);
expect(selectedChangedValue).toBe(theme.fg("statusLineGitDirty", "changed"));
expect(unselectedChangedValue).toBe(theme.fg("statusLineGitDirty", "changed"));
expect(selectedDefaultValue).toBe(theme.fg("accent", "default"));
});
});