From 4fd9dd5722198fed194153b585f004af97405ce3 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 21 Jul 2026 20:47:39 +0000 Subject: [PATCH] fix(usage): align multi-account /usage columns across windows The TUI /usage matrix sorted each quota window's account columns independently by used fraction, so the positional `account N` labels denoted different credentials on each row. An account exhausted on one window but light on another (e.g. a Kimi Code account's 5h limit) rendered its exhausted bar under a sibling that still had quota, making the section `% free` and capacity numbers look wrong. Order account columns once per provider (worst-first) and hold that order stable across every window row. Fixes #6067 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../modes/controllers/command-controller.ts | 25 +++++++-- .../src/prompts/system/workflow-notice.md | 6 +- .../test/usage-report-column-align.test.ts | 56 +++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/test/usage-report-column-align.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f32dc885..e917ff69e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the TUI `/usage` matrix mis-aligning multi-account columns across quota windows: each window row was sorted independently by used fraction, so the positional `account N` labels denoted different credentials per row and an exhausted limit (e.g. a Kimi Code account's 5h window) could render under a sibling that still had quota. Account columns are now ordered once per provider (worst-first) and held stable across every window row ([#6067](https://github.com/can1357/oh-my-pi/issues/6067)). + ## [17.0.5] - 2026-07-18 ### Added diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 6d21be4de..04f2936fc 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1817,17 +1817,34 @@ export function renderUsageReports( for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line)); } + // Order account columns ONCE per provider (worst-first), then apply that + // same order to every window group. Sorting each group independently by + // its own used fraction (issue #6067) desynchronized the columns: an + // account exhausted on its 5h window but light on the weekly window would + // land in different column positions on each row, so the positional + // `account N` labels denoted different credentials per row and an + // exhausted limit appeared under a sibling that still had quota. + const accountRank = new Map(); + providerReports.forEach((report, position) => { + const worst = report.limits.reduce((max, limit) => { + const fraction = resolveUsedFraction(limit) ?? -1; + return fraction > max ? fraction : max; + }, -1); + // Encode worst-first primary key with the stable position as tiebreak + // so accounts tied on pressure keep their discovery order. + accountRank.set(report, -worst * 1000 + position); + }); + const renderableGroups = Array.from(limitGroups.values()).map(group => { const entries = group.limits.map((limit, index) => ({ limit, report: group.reports[index], - fraction: resolveUsedFraction(limit), index, })); entries.sort((a, b) => { - const aFraction = a.fraction ?? -1; - const bFraction = b.fraction ?? -1; - if (aFraction !== bFraction) return bFraction - aFraction; + const aRank = accountRank.get(a.report) ?? a.index; + const bRank = accountRank.get(b.report) ?? b.index; + if (aRank !== bRank) return aRank - bRank; return a.index - b.index; }); const sortedLimits = entries.map(entry => entry.limit); diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index d15ad9127..09a849bef 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -47,7 +47,7 @@ For independent per-item chains (review → verify, fetch → extract → score) schema: FINDINGS_SCHEMA, }); return await parallel(found.findings.map((f) => async () => ({ - ...f, + …f, verdict: await agent( `Refute if you can (default refuted when unsure): ${f.title}`, { label: `verify:${f.file}`, schema: VERDICT_SCHEMA }, @@ -57,8 +57,6 @@ For independent per-item chains (review → verify, fetch → extract → score) phase("Review"); const results = await parallel(DIMENSIONS.map((d) => async () => reviewAndVerify(d))); const confirmed = results.flat().filter((f) => f.verdict.is_real); - - Reach for `pipeline()` only when a stage genuinely needs ALL of the previous stage first — dedup/merge across the whole set, early-exit on zero, or "compare against the other findings" — because its inter-stage barrier makes every item wait for the slowest peer: **Python (`eval`, Python backend):** @@ -80,8 +78,6 @@ Reach for `pipeline()` only when a stage genuinely needs ALL of the previous sta const verdicts = await parallel(findings.map((f) => async () => await agent(verifyPrompt(f), { schema: VERDICT_SCHEMA }), )); - - Use ordinary code between calls to flatten/map/filter; don't add a barrier just for that. Nested `parallel()` pools each cap independently, so keep total fan-out sane. diff --git a/packages/coding-agent/test/usage-report-column-align.test.ts b/packages/coding-agent/test/usage-report-column-align.test.ts new file mode 100644 index 000000000..11eb14ef0 --- /dev/null +++ b/packages/coding-agent/test/usage-report-column-align.test.ts @@ -0,0 +1,56 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; +import type { UsageReport } from "@oh-my-pi/pi-ai"; +import { renderUsageReports } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller"; +import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; + +const HOUR = 3_600_000; + +beforeAll(async () => { + await initTheme(); +}); + +function win(label: string, windowId: string, durationMs: number, frac: number) { + return { + id: windowId, + label, + scope: { provider: "kimi-code", windowId }, + window: { id: windowId, label, durationMs }, + amount: { unit: "percent", usedFraction: frac }, + status: frac >= 1 ? "exhausted" : frac >= 0.9 ? "warning" : "ok", + } satisfies UsageReport["limits"][number]; +} + +function acct(email: string, total: number, fiveH: number): UsageReport { + return { + provider: "kimi-code", + fetchedAt: Date.now(), + metadata: { email }, + limits: [ + win("Total quota", "usage-window", 7 * 24 * HOUR, total), + win("5h limit", "rolling-5h", 5 * HOUR, fiveH), + ], + } satisfies UsageReport; +} + +describe("renderUsageReports multi-account column alignment (#6067)", () => { + it("keeps account columns in the same order across every window row", () => { + // Account A: weekly exhausted, 5h free. Account B: weekly light, 5h exhausted. + // A naive per-window sort by used fraction swaps the columns between rows. + const reports: UsageReport[] = [acct("alice@example.test", 1.0, 0.0), acct("bob@example.test", 0.2, 1.0)]; + const text = stripVTControlCharacters(renderUsageReports(reports, theme, Date.now(), 160)); + const lines = text.split("\n"); + + const columnOrder = (sectionLabel: string): string[] => { + const headerIdx = lines.findIndex(l => l.includes(sectionLabel)); + expect(headerIdx).toBeGreaterThanOrEqual(0); + // The account-label row is the line right after the section header. + const labelRow = lines[headerIdx + 1]; + return ["alice@example.test", "bob@example.test"].sort((a, b) => labelRow.indexOf(a) - labelRow.indexOf(b)); + }; + + const totalOrder = columnOrder("Total quota"); + const fiveHOrder = columnOrder("5h limit"); + expect(fiveHOrder).toEqual(totalOrder); + }); +});