diff --git a/packages/ai/src/usage/openai-codex-reset.ts b/packages/ai/src/usage/openai-codex-reset.ts index 56dca5987..8efb86c6c 100644 --- a/packages/ai/src/usage/openai-codex-reset.ts +++ b/packages/ai/src/usage/openai-codex-reset.ts @@ -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"; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7d32db36e..ededeb5da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/components/reset-usage-selector.ts b/packages/coding-agent/src/modes/components/reset-usage-selector.ts index 990616ec3..a176bb32b 100644 --- a/packages/coding-agent/src/modes/components/reset-usage-selector.ts +++ b/packages/coding-agent/src/modes/components/reset-usage-selector.ts @@ -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. */ diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 1ffc6d86e..4e642a0d3 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -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)); } diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index f65b1941a..3258e705a 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -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), diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2fbd07226..c23f74185 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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 { @@ -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 { return this.#modelRegistry.authStorage.listResetCredits({ diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 195eafaf5..b0de73542 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -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 { + 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 ` 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 "; function resolveDebugRequestDumpPath(target: string, cwd: string): string { @@ -593,71 +642,41 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ 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; - 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 ` 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(""); }, }, diff --git a/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts b/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts index fba3780cb..90f48b892 100644 --- a/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts +++ b/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts @@ -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. */ diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts index 54bf75208..c3c06f35f 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -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) { diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index 9e35757cf..8164e022c 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -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; + listResetCredits: () => Promise; + redeemResetCredit: (target: ResetCreditTarget) => Promise; } 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(); diff --git a/packages/coding-agent/test/available-commands.test.ts b/packages/coding-agent/test/available-commands.test.ts index 3ce705a3d..dd7460f76 100644 --- a/packages/coding-agent/test/available-commands.test.ts +++ b/packages/coding-agent/test/available-commands.test.ts @@ -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"); diff --git a/packages/coding-agent/test/modes/theme/settings-list-theme.test.ts b/packages/coding-agent/test/modes/theme/settings-list-theme.test.ts index 11db0f895..1e5d80653 100644 --- a/packages/coding-agent/test/modes/theme/settings-list-theme.test.ts +++ b/packages/coding-agent/test/modes/theme/settings-list-theme.test.ts @@ -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")); }); });