diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1304bbe295..1158e12ec2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - Fixed `/usage` and `/usage check` omitting provider limits for stored OAuth accounts. Cache-only snapshots now use the provider's resolved base URL when reading usage, and explicit checks render the successful probe report directly instead of depending on a cache-key-identical readback (#4634). +- `/usage` shows quota resets again. Canonicalizing multi-account management (`364f14022`) rewired the interactive `/usage` handler from the graphical panel to the account-inventory text view, and that view rendered only `label: N% used (M% left)` — no bars, no reset countdown — leaving the command unable to answer when a quota comes back and stranding `handleUsageCommand`/`renderUsageReports` as unreachable code. Plain `/usage` in the TUI renders the panel again, sourced from the same cache-only inventory snapshot the text view reads, so the cache-only contract is preserved and no fetch or probe is reintroduced; `/usage check` keeps the text path, where the per-credential health verdict is the point. Account rows on every surface (TUI, ACP, Telegram) now carry `resets in ()`, and the panel itself gained multi-account reset lines, hour-precision countdowns past 48h (`6d 14h`, previously rounded to a bare `7d` at anywhere from 6.6 to 7.4 days), and set-aware account-label truncation so pooled credentials sharing a domain no longer collapse into identical columns. - Fixed resume listing scaling its read-syscall count with total transcript bytes. The trailing `header_patch` scan walks back to BOF whenever `cwd`/`title` stay unresolved (#3633), which is the common case because only `/rename` and workspace moves ever emit a patch; because the scan borrowed the caller's 4 KiB prefix buffer, that walk cost one `read` per 4 KiB of every candidate transcript on each `--resume`, `--continue`, and picker open. The scan now owns a 64 KiB buffer, so the same bytes are covered in ~16x fewer syscalls. Measured on a real 31-session workspace holding 105 MB of transcripts (largest 41 MB): 25,715 reads / 61.9 s before, 1,652 reads / 0.5 s after, with all 24 recovered titles unchanged. Buried-title recovery, the bytes examined, the `header_patch` marker prefilter, and listing results are unchanged. - `gjc team` worker auto-checkpoints no longer commit and merge root-level worker runtime state (`.gjc/state/**`, e.g. SDK broker endpoints like `.gjc/state/sdk/.json` and settings migration markers) into the leader repo's default branch. The checkpoint classifier's protected prefixes now cover both GJC runtime roots — `.gjc/_session-*/` and `.gjc/state/` — while user-owned `.gjc/` content (config, agents, skills) stays eligible as reviewable worker work. The worker-runtime-state e2e guard now asserts absence at the actual leader merge-target path instead of an unrelated session-scoped path, and matcher boundary cases (`.gjc/state` bare entry vs `.gjc/state-*` siblings) are pinned (#4603). - Fixed Telegram forum topics freezing after the identity header: an attached, trusted session whose topic-host lease expired (20 s `HEARTBEAT_TTL_MS`) could never renew it, because `renewActiveTopicLeases` only renewed sessions that already passed the trusted-lease gate, so every later `turn_stream`/`context_update`/tool frame was rejected pre-send with "trusted attachment lease is stale" and the topic never updated again (#4647). A live attachment that still owns its exact logical session and holds an authorized recovery lease may now re-arm its own expired host lease — from the ownership heartbeat and once more before the publication gate — mirroring `acquireLease` admission (expired-but-owned active lease, or a same-owner resume inside the disconnect-grace window, which also covers the incident's persisted `disconnect_grace` record). Dropped sessions, closed endpoints, foreign lease owners, archive-fenced/inactive topics, malformed bindings, and cross-session ownership checks all still fail closed. Daemon generation bumped 169→170. diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 9aea32a4ec..cc0e1509ed 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -629,7 +629,9 @@ export class CommandController { if (!usageReports) { const provider = this.ctx.session as { fetchUsageReports?: () => Promise }; if (!provider.fetchUsageReports) { - this.ctx.showWarning("Usage reporting is not configured for this session."); + this.ctx.showWarning( + "Usage reporting is not configured for this session: no provider exposes usage limits here.", + ); return; } try { @@ -641,7 +643,9 @@ export class CommandController { } if (!usageReports || usageReports.length === 0) { - this.ctx.showWarning("No usage data available."); + this.ctx.showWarning( + "No usage data available: the configured providers reported no limits. Subscription/OAuth credentials report limits; plain API keys usually do not.", + ); return; } @@ -1604,6 +1608,39 @@ function formatUnlimitedReportLabel(report: UsageReport, reportIndex: number): s return `account ${reportIndex + 1}`; } +/** + * Two-unit countdown for usage windows. `formatDuration` collapses everything + * past 48h to a single rounded unit, so a weekly window reads `7d` whether 6.6 + * or 7.4 days remain — useless when the question is "how long until I get my + * quota back". Kept local to the usage panel so job/elapsed rendering keeps the + * coarse label. + */ +function formatResetCountdown(ms: number): string { + const totalMinutes = Math.max(0, Math.floor(ms / 60_000)); + if (totalMinutes < 1) return "<1m"; + if (totalMinutes < 60) return `${totalMinutes}m`; + const totalHours = Math.floor(totalMinutes / 60); + if (totalHours < 48) { + const minutes = totalMinutes % 60; + return minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`; + } + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; +} + +/** Absolute local reset time, so a long countdown maps onto a real calendar day. */ +function formatResetAt(resetsAt: number, nowMs: number): string { + const date = new Date(resetsAt); + const withinADay = resetsAt - nowMs < 24 * 3_600_000; + return date.toLocaleString(undefined, { + month: withinADay ? undefined : "short", + day: withinADay ? undefined : "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined { if (limit.window?.resetsAt !== undefined) { return formatDuration(limit.window.resetsAt - nowMs); @@ -1611,6 +1648,61 @@ function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined return undefined; } +/** Squeeze a label from the middle, keeping both ends. */ +function squeezeMiddle(label: string, maxWidth: number): string { + if (visibleWidth(label) <= maxWidth) return label; + if (maxWidth <= 3) return truncateJobLabel(label, maxWidth); + const chars = [...label]; + const headBudget = Math.ceil((maxWidth - 1) / 2); + const tailBudget = maxWidth - 1 - headBudget; + let head = ""; + for (const char of chars) { + if (visibleWidth(head + char) > headBudget) break; + head += char; + } + let tail = ""; + for (let index = chars.length - 1; index >= 0; index--) { + const next = chars[index]! + tail; + if (visibleWidth(next) > tailBudget) break; + tail = next; + } + return `${head}…${tail}`; +} + +function accountLocalPart(label: string): string { + const at = label.lastIndexOf("@"); + return at > 0 ? label.slice(0, at) : label; +} + +/** + * Fit account labels into `maxWidth` while keeping them mutually + * distinguishable. Credentials pooled on one provider share a domain and often + * a prefix, so truncating each label in isolation collapses every column into + * the same stub — which defeats the only reason the panel shows accounts at + * all. Try progressively cheaper representations and keep the first one that is + * still unique; if nothing is, tag the columns with an index so the rows can at + * least be told apart. + */ +function truncateAccountLabels(labels: string[], maxWidth: number): string[] { + const distinctInput = new Set(labels).size; + const strategies: ((label: string) => string)[] = [ + label => label, + label => accountLocalPart(label), + label => truncateJobLabel(accountLocalPart(label), maxWidth), + label => squeezeMiddle(accountLocalPart(label), maxWidth), + ]; + for (const strategy of strategies) { + const candidate = labels.map(strategy); + const fits = candidate.every(entry => visibleWidth(entry) <= maxWidth); + if (fits && new Set(candidate).size === distinctInput) return candidate; + } + return labels.map((label, index) => { + const tag = `#${index + 1}`; + const budget = Math.max(1, maxWidth - visibleWidth(tag)); + return `${truncateJobLabel(accountLocalPart(label), budget)}${tag}`; + }); +} + function formatAccountHeaderRow( limits: UsageLimit[], reports: UsageReport[], @@ -1638,9 +1730,13 @@ function formatAccountHeaderRow( }); } - return parts.map(p => { - const prefix = truncateJobLabel(p.label, prefixBudget); - const prefixCell = prefix + " ".repeat(prefixBudget - visibleWidth(prefix)); + const prefixes = truncateAccountLabels( + parts.map(p => p.label), + prefixBudget, + ); + return parts.map((p, index) => { + const prefix = prefixes[index]!; + const prefixCell = prefix + " ".repeat(Math.max(0, prefixBudget - visibleWidth(prefix))); if (!p.suffix) return prefixCell + " ".repeat(maxSuffixWidth + gap); const suffixPad = " ".repeat(maxSuffixWidth - visibleWidth(p.suffix)); return `${prefixCell} ${suffixPad}${uiTheme.fg("dim", p.suffix)}`; @@ -1688,18 +1784,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string { return `${limits.length} accts`; } +/** + * Reset line for one window. Renders a range when the accounts in the window + * reset at materially different times, and pins the earliest reset to an + * absolute local time so "when do I get my quota back" is answerable without + * doing date arithmetic in your head. + */ function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null { const absolute = limits .map(limit => limit.window?.resetsAt) .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs); if (absolute.length === 0) return null; - const offsets = absolute.map(value => value - nowMs); - const minReset = Math.min(...offsets); - const maxReset = Math.max(...offsets); - if (maxReset - minReset > 60_000) { - return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`; + const earliest = Math.min(...absolute); + const latest = Math.max(...absolute); + const at = formatResetAt(earliest, nowMs); + if (latest - earliest > 60_000) { + return `resets in ${formatResetCountdown(earliest - nowMs)}–${formatResetCountdown(latest - nowMs)} (first ${at})`; } - return `resets in ${formatDuration(minReset)}`; + return `resets in ${formatResetCountdown(earliest - nowMs)} (${at})`; } function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string { @@ -1868,7 +1970,7 @@ export function renderUsageReports( padColumn(renderUsageBar(limit, uiTheme, sectionColumnWidth), sectionColumnWidth), ); lines.push(` ${bars.join(" ")} ${amountText}`.trimEnd()); - const resetText = sortedLimits.length <= 1 ? resolveResetRange(sortedLimits, nowMs) : null; + const resetText = resolveResetRange(sortedLimits, nowMs); if (resetText) { lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd()); } diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 587165de8c..c1659ecef9 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -50,7 +50,7 @@ import { buildFastStatusReport } from "./helpers/fast-status-report"; import { formatDuration } from "./helpers/format"; import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse"; import { handleSshAcp } from "./helpers/ssh"; -import { buildUsageReportText } from "./helpers/usage-report"; +import { buildUsageReportText, collectCachedUsageReports } from "./helpers/usage-report"; import type { BuiltinSlashCommand, ParsedSlashCommand, @@ -1348,7 +1348,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ runtime.ctx.showError("Usage: /usage [check]"); } else { const adapted = toSlashCommandRuntime(runtime); - await adapted.output(await buildUsageReportText(adapted, { check: args === "check" })); + // Plain `/usage` renders the graphical panel from the cache-only + // inventory snapshot — same data the text view reads, no fetch or + // probe. `/usage check` stays on the text path because its value is + // the per-credential health verdict, not the bars. + const cached = args === "" ? collectCachedUsageReports(adapted) : []; + if (cached.length > 0) { + await runtime.ctx.handleUsageCommand(cached); + } else { + await adapted.output(await buildUsageReportText(adapted, { check: args === "check" })); + } } runtime.ctx.editor.setText(""); }, 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 3bfda5fa83..dfd62fbde2 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -1,4 +1,4 @@ -import type { UsageLimit } from "@gajae-code/ai/core"; +import type { UsageLimit, UsageReport } from "@gajae-code/ai/core"; import { sanitizeText } from "@gajae-code/utils"; import { type AccountInventoryRow, @@ -34,6 +34,60 @@ function healthLabel(row: AccountInventoryRow): string { return "unknown"; } +/** + * Reset detail for one limit. The account rows dropped every reset signal when + * the panel was replaced, which left `/usage` unable to answer the question it + * exists for: how long until the quota comes back. Two-unit precision, because + * a single rounded unit reads `7d` at both 6.6 and 7.4 days remaining. + */ +function formatLimitReset(limit: UsageLimit, nowMs: number): string { + const resetsAt = limit.window?.resetsAt; + if (resetsAt === undefined || !Number.isFinite(resetsAt) || resetsAt <= nowMs) return ""; + const totalMinutes = Math.floor((resetsAt - nowMs) / 60_000); + const totalHours = Math.floor(totalMinutes / 60); + let countdown: string; + if (totalMinutes < 1) countdown = "<1m"; + else if (totalMinutes < 60) countdown = `${totalMinutes}m`; + else if (totalHours < 48) { + const minutes = totalMinutes % 60; + countdown = minutes > 0 ? `${totalHours}h ${minutes}m` : `${totalHours}h`; + } else { + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + countdown = hours > 0 ? `${days}d ${hours}h` : `${days}d`; + } + const withinADay = resetsAt - nowMs < 24 * 3_600_000; + const at = new Date(resetsAt).toLocaleString(undefined, { + month: withinADay ? undefined : "short", + day: withinADay ? undefined : "numeric", + hour: "2-digit", + minute: "2-digit", + }); + return `, resets in ${countdown} (${at})`; +} + +/** One limit line: how much is left, and when it comes back. */ +export function formatLimitDetail(limit: UsageLimit, nowMs: number): string { + return `${formatUsageAmount(limit)}${formatLimitReset(limit, nowMs)}`; +} + +/** + * Cache-only usage reports for the interactive panel. Mirrors the plain + * `/usage` contract exactly — reads the account inventory snapshot, never + * fetches or probes — so the graphical view can be restored without + * reintroducing the network call that motivated replacing it. + */ +export function collectCachedUsageReports(runtime: SlashCommandRuntime): UsageReport[] { + const session = runtime.session; + const modelRegistry = session.modelRegistry; + const snapshot = buildAccountInventorySnapshot({ + authStorage: modelRegistry.authStorage, + modelRegistry, + sessionId: session.credentialSessionId ?? session.sessionId, + }); + return snapshot.rows.flatMap(row => (row.usage ? [row.usage.report] : [])); +} + function renderAccountRows(rows: AccountInventoryRow[], nowMs: number, checked: boolean): string { const lines = [`Accounts${checked ? " (checked)" : " (cache only)"}`]; if (rows.length === 0) { @@ -52,7 +106,7 @@ function renderAccountRows(rows: AccountInventoryRow[], nowMs: number, checked: ); if (row.usage?.report.limits.length) { for (const limit of row.usage.report.limits.slice(0, 8)) { - lines.push(` ${sanitizeText(limit.label)}: ${formatUsageAmount(limit)}`); + lines.push(` ${sanitizeText(limit.label)}: ${formatLimitDetail(limit, nowMs)}`); } } } diff --git a/packages/coding-agent/test/usage-report-columns.test.ts b/packages/coding-agent/test/usage-report-columns.test.ts index bffb00c0af..b5719dbfd0 100644 --- a/packages/coding-agent/test/usage-report-columns.test.ts +++ b/packages/coding-agent/test/usage-report-columns.test.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, test } from "bun:test"; import type { UsageLimit, UsageReport } from "@gajae-code/ai"; import { renderUsageReports } from "@gajae-code/coding-agent/modes/controllers/command-controller"; import { getThemeByName, setThemeInstance, theme } from "@gajae-code/coding-agent/modes/theme/theme"; +import { formatLimitDetail } from "@gajae-code/coding-agent/slash-commands/helpers/usage-report"; function stripAnsi(text: string): string { return text.replace(/\x1b\[[0-9;]*m/g, ""); @@ -115,3 +116,81 @@ describe("usage report column ordering", () => { } }); }); + +describe("usage report reset visibility", () => { + beforeAll(async () => { + const loaded = await getThemeByName("red-claw"); + if (loaded) setThemeInstance(loaded); + }); + + test("multi-account windows still render a reset line", () => { + const reports = [report("alice@example.com", 0.2, 0.6), report("bob@example.com", 0.5, 0.1)]; + const output = stripAnsi(renderUsageReports(reports, theme, NOW, 100)); + + expect(output.match(/resets in /g)).toHaveLength(2); + }); + + test("divergent resets render a range, identical resets render one value", () => { + const skewed = { + ...report("carol@example.com", 0.3, 0.3), + limits: [ + limit("5h", "Claude 5 Hour", "5 Hour", 4 * 3_600_000, 0.3), + limit("7d", "Claude 7 Day", "7 Day", 5 * 86_400_000, 0.3), + ], + } as UsageReport; + const lines = stripAnsi(renderUsageReports([report("alice@example.com", 0.2, 0.6), skewed], theme, NOW, 100)) + .split("\n") + .filter(line => line.includes("resets in ")); + + expect(lines).toHaveLength(2); + // 5h window: alice resets in 2h, carol in 4h → a range. + expect(lines[0]).toMatch(/resets in 2h–4h \(first .+\)/); + // 7d window: both reset at the same instant → a single value. + expect(lines[1]).toMatch(/^\s*resets in 5d \(.+\)$/); + }); + + test("windows past 48h keep hour precision instead of collapsing to one unit", () => { + const coarse = { + ...report("dave@example.com", 0.1, 0.1), + limits: [limit("7d", "Claude 7 Day", "7 Day", 6 * 86_400_000 + 14 * 3_600_000, 0.1)], + } as UsageReport; + const output = stripAnsi(renderUsageReports([coarse], theme, NOW, 100)); + + expect(output).toContain("resets in 6d 14h"); + }); + + test("account labels stay distinguishable when columns are tight", () => { + const crowded = ["one", "two", "three", "four", "five"].map(name => + report(`${name}.longlocalpart@example.com`, 0.2, 0.2), + ); + const lines = stripAnsi(renderUsageReports(crowded, theme, NOW, 80)).split("\n"); + const header = lines[lines.findIndex(line => line.includes("Claude 5 Hour")) + 1] ?? ""; + // Strip the shared `(reset)` suffix so uniqueness is judged on identity alone. + const labels = header + .trim() + .split(/\s*\([^)]*\)\s*/) + .map(cell => cell.trim()) + .filter(Boolean); + + expect(labels).toHaveLength(crowded.length); + expect(new Set(labels).size).toBe(crowded.length); + }); +}); + +describe("usage text rows", () => { + test("limit lines carry the reset countdown and an absolute reset time", () => { + const weekly = limit("7d", "Claude 7 Day", "7 Day", 6 * 86_400_000 + 14 * 3_600_000, 0.24); + const detail = formatLimitDetail(weekly, NOW); + + expect(detail).toContain("24.00% used"); + expect(detail).toContain("resets in 6d 14h"); + }); + + test("expired or missing reset windows add no reset text", () => { + const past = limit("5h", "Claude 5 Hour", "5 Hour", -3_600_000, 0.5); + const windowless = { ...past, window: undefined } as UsageLimit; + + expect(formatLimitDetail(past, NOW)).not.toContain("resets in"); + expect(formatLimitDetail(windowless, NOW)).not.toContain("resets in"); + }); +});