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:
@@ -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";
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user