Skip to content

feat(cli): show Session pace and add pace to JSON output - #10

Open
kmatsunami wants to merge 13 commits into
mainfrom
feat/cli-session-and-json-pace
Open

feat(cli): show Session pace and add pace to JSON output#10
kmatsunami wants to merge 13 commits into
mainfrom
feat/cli-session-and-json-pace

Conversation

@kmatsunami

@kmatsunami kmatsunami commented Jun 22, 2026

Copy link
Copy Markdown
Owner

What was missing

codexbar already computes pace, but coverage was uneven:

  • Text (codexbar usage): a Pace line is shown for Weekly but not for Session.
  • JSON (--format json): no pace information is emitted at all.

This PR fills both gaps so pace is consistent across windows and output formats.

Changes

  • Text: adds a Pace line under Session, matching the existing Weekly line. Uses the same session-pace calculation as the menu bar (UsagePaceText.sessionPace; 5-hour window; codex/claude/ollama), with the GUI's "Projected empty in …" wording (Weekly keeps "Runs out in …").
  • JSON (--format json): adds a derived pace object to each provider payload.
  • Docs: updates the docs/cli.md sample output (text + JSON) to reflect the new Session pace line and pace object.

Sample output

Lines this PR adds are highlighted in green (+); everything else is existing output shown for context.

Text (codexbar usage)

 == Codex 0.6.0 (codex-cli) ==
 Session: 72% left [========----]
+Pace: 12% in deficit | Expected 16% used | Projected empty in 2h 30m
 Resets today at 2:15 PM
 Weekly: 41% left [====--------]
 Pace: 6% in reserve | Expected 47% used | Lasts until reset
 Resets Fri at 9:00 AM

 == Claude Code 2.0.58 (web) ==
 Session: 88% left [==========--]
+Pace: On pace | Expected 13% used | Lasts until reset
 Resets tomorrow at 1:00 AM
 Weekly: 63% left [=======-----]
 Pace: On pace | Expected 37% used | Runs out in 4d
 Resets Sat at 6:00 AM

JSON (codexbar usage --format json --pretty)

 {
   "provider": "codex",
   "version": "0.6.0",
   "source": "openai-web",
   "usage": {
     "primary":   { "usedPercent": 28, "windowMinutes": 300,   "resetsAt": "2025-12-04T19:15:00Z" },
     "secondary": { "usedPercent": 59, "windowMinutes": 10080, "resetsAt": "2025-12-05T17:00:00Z" },
     "tertiary": null,
     "updatedAt": "2025-12-04T18:10:22Z"
   },
+  "pace": {
+    "primary":   { "stage": "ahead",          "deltaPercent": 12, "expectedUsedPercent": 16, "willLastToReset": false, "etaSeconds": 9000, "summary": "12% in deficit | Expected 16% used | Projected empty in 2h 30m" },
+    "secondary": { "stage": "slightlyBehind", "deltaPercent": -6, "expectedUsedPercent": 47, "willLastToReset": true,                      "summary": "6% in reserve | Expected 47% used | Lasts until reset" }
+  },
   "credits": { "remaining": 112.4, "updatedAt": "2025-12-04T18:10:21Z" }
 }

pace.primary = session window, pace.secondary = weekly window. A key is emitted only when the corresponding text Pace line would render; nil fields (e.g. etaSeconds when it lasts to reset) are omitted, consistent with the existing payload. deltaPercent = used − expected, rounded to whole values (positive = ahead of pace / deficit, negative = reserve); the raw usage figure stays in usage.<window>.usedPercent.

Notes

  • Session pace mirrors UsagePaceText.sessionPace. Weekly is a plain linear approximation in the CLI — unlike the menu's UsageStore.weeklyPace it does not apply weeklyProgressWorkDays or Codex historical refinement, and uses a fixed provider allowlist. (Pre-existing for the text weekly line; documented on PaceKind.)
  • A non-session primary window (e.g. Claude with no 5-hour data falling back to a 7-day window) is not paced as a "Session".
  • Additive only — no existing JSON keys changed.

Test

  • swift test --filter CLISnapshotTests — 35 passing (session-pace text + JSON pace, rounding, omission, on-track / lasts-until-reset, non-session primary gating).
  • swiftlint --strict and swiftformat --lint clean.

🤖 Generated with Claude Code

@kmatsunami kmatsunami left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — Session pace + JSON pace

Reviewed at f7cf2f0 (xhigh-effort pass: 10 finder angles + verification + gap sweep). No functional bugs — no crashes, nil-derefs, broken call sites, dropped guards, or broken existing tests. pace is correctly added to the explicit CodingKeys; the synthesized encoder correctly omits nil keys; the rounding update makes the summary string and the numeric fields agree.

The one cross-cutting theme: the code comments say the output "mirrors the GUI," and that holds for session pace (computePace(.session) matches UsagePaceText.sessionPace exactly). It does not hold for weekly pace — the CLI uses plain linear UsagePace.weekly while the menu's UsageStore.weeklyPace applies work-days, Codex historical refinement, and a broader provider set. That weekly gap was pre-existing in the text output, but this PR now publishes it as a machine-readable JSON number, so it's worth being explicit about. Everything else is maintainability (the pace logic now lives in three independent copies).

Nothing here blocks the PR. Inline notes below, most-impactful first.

if provider == .ollama, window.windowMinutes == nil { return nil }
guard window.remainingPercent > 0 else { return nil }
guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil }
guard let pace = UsagePace.weekly(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weekly pace diverges from the menu it claims to mirror. This calls plain linear UsagePace.weekly(...) with no workDays and no Codex historical refinement, whereas the GUI's UsageStore.weeklyPace (UsageStore+HistoricalPace.swift:9) passes settings.weeklyProgressWorkDays and routes Codex through CodexHistoricalPaceEvaluator.

So for a Codex user with historical tracking on, or any user whose weeklyProgressWorkDays != 7, codexbar usage --format json pace.secondary (and the weekly text Pace line) report a different stage/deltaPercent/etaSeconds than the menu bar. The session path is faithful; only weekly diverges. Either route weekly through the same evaluator, or soften the "mirrors the GUI" wording to "mirrors the GUI's session pace; weekly is a plain linear approximation."

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Softened the PaceKind comment in 44a1afc: only .session claims GUI parity; .weekly is now documented as a plain linear approximation (no workDays / Codex historical / windowMinutes-only gating). Routing weekly through the GUI evaluator isn't possible from CLI (CodexBarCLI can't import CodexBar/UsageStore), so this is wording-only here; full parity tracked with C6 as a follow-up.

case .session:
provider == .codex || provider == .claude || provider == .ollama
case .weekly:
provider == .codex || provider == .claude || provider == .opencode || provider == .ollama

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weekly provider coverage is narrower than the menu. This allowlist gates weekly pace to codex/claude/opencode/ollama, but UsageStore.weeklyPace shows a weekly Pace line for any provider whose secondary window carries windowMinutes (cursor, factory-weekly, gemini, …). Those providers get no pace.secondary in the JSON and no weekly text Pace line, even though the menu paces them. Pre-existing for the text path, but now also a silent gap in the JSON contract.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: GUI gates weekly on windowMinutes != nil for any provider (UsageStore+HistoricalPace.swift:32), CLI uses the codex/claude/opencode/ollama allowlist. Pre-existing in the text path; left unchanged here and the comment now documents the divergence. Dropping the allowlist to match the GUI rule is deferred to a follow-up (it changes existing text output too).

title: labels.primary,
window: primary,
includePace: false,
paceKind: .session,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up on a corner case (matches the GUI, so likely intended, but new to the CLI): when a Claude account has no 5-hour data, ClaudeUsageFetcher falls the primary back to the 7-day window (windowMinutes = 10080, kind .usageClaudeUsageFetcher.swift:962). It survives the spend-limit nil filter and reaches .session here, so a pace computed over 7 days prints under the "Session" label with "Projected empty in 2d" wording. The math is correct (it uses the real 10080-min window), it just reads oddly as a "session." Mirrors UsagePaceText.sessionSummary(primary).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — intended, matches UsagePaceText.sessionSummary(primary). No change.

let willLastToReset: Bool
/// Seconds until the window is projected to be exhausted. Omitted when it lasts to reset or is unknown.
let etaSeconds: TimeInterval?
let runOutProbability: Double?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runOutProbability is a structurally-dead field for the CLI: every CLI pace flows through UsagePace.weekly, which hard-codes runOutProbability: nil (UsagePace.swift:113), and the CLI never calls UsagePace.historical. So this key is never populated, and the CLI summary can never carry the GUI's "≈ X% run-out risk" clause that UsagePaceText.detailRightLabel appends. Fine to keep for schema-parity, but worth a doc note that it's always absent in CLI output.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented on the field in 44a1afc: runOutProbability is always absent in CLI output (CLI computes via UsagePace.weekly, never .historical). Kept for schema parity.

Comment thread Sources/CodexBarCLI/CLIPayloads.swift

/// Computes the pace for a window using the same guards/window length as the GUI, or nil when no
/// Pace line should be shown. Shared by the text renderer and the JSON payload so they stay in sync.
private static func computePace(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainability / altitude (the big one). Pace logic now lives in three independent copies: this computePace byte-copies the guards of UsagePaceText.sessionPace; paceLeftLabel/paceRightLabel/paceDurationText copy detailLeftLabel/detailRightLabel/durationText; and UsageStore.weeklyPace is a third. The new shared helper only unifies CLI text+JSON — the GUI is still free to drift.

The magic numbers 300/10080/3 are likewise duplicated across CLIRenderer, UsagePace, UsagePaceText, and UsageStore, and stageString is a hand-written 7-case switch that must track UsagePace.Stage.

Deeper fix: lift a shared sessionPace/weeklyPace + the label builders + the constants into CodexBarCore (the only module both CodexBar and CodexBarCLI depend on), and make UsagePace.Stage a String-backed enum so any serializer shares one spelling. Reasonable to do as a follow-up, but flagging so the next pace edit doesn't silently desync CLI from the menu.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed; deferred to a follow-up issue: lift shared sessionPace/weeklyPace + label builders + the 300/10080/3 constants into CodexBarCore, and make UsagePace.Stage String-backed (removing the hand-written stageString). Out of scope for this PR per scope decision.

}

@Test
func `renders session pace line when session window has reset`() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two test notes:

  1. Timing coupling: this and the other two renderText-based pace tests build the snapshot with one Date(), but renderText captures its own Date() (CLIRenderer.swift:19), so the literal substring expectations (Expected 60% used) depend on rounding absorbing the inter-call drift. The margin to the rounding boundary here is ~90s so it won't flake in practice — but the JSON tests' fixed-now pattern is the deterministic one; consider passing now through to renderText in these too.
  2. Coverage gap: no test exercises the onTrack boundary ("On pace", |delta| ≤ 2), the willLastToReset → "Lasts until reset" branch, or the eta-rounds-to-"now" wording. Every pace test uses delta 30/−40 and eta "in …", so a regression at those boundaries would ship green.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

44a1afc: (1) added a now parameter to renderText and threaded it into the exact-string pace tests, removing the inter-call Date() drift. (2) added an on-track / 'Lasts until reset' boundary test. The eta-rounds-to-'now' wording is effectively unreachable via the public path: resetCountdownDescription returns 'now' only for eta < 1s, but the expectedUsedPercent >= 3 guard forces elapsed >= 540s of the 300-min window, so the minimum reachable eta is ~5s ('in 1m'). Noted rather than adding an un-constructable test.

openaiDashboard: OpenAIDashboardSnapshot?,
error: ProviderErrorPayload?)
error: ProviderErrorPayload?,
pace: ProviderPacePayload? = nil)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

=nil は要るの?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

要ります。エラー経路の ProviderPayload 構築(CLIErrorReporting.swift:35/58/115・CLIHelpers.swift:325)と一部テストが pace 無しで生成しており、同じ init の既存 cacheAccountKey: String? = nil と同じ慣習です。外すと各呼び出しで pace: nil 明示が必要になり、cacheAccountKey と不揃いになります。

openaiDashboard: OpenAIDashboardSnapshot?,
error: ProviderErrorPayload?)
error: ProviderErrorPayload?,
pace: ProviderPacePayload? = nil)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

=nil は要るの?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

要ります。エラー経路の ProviderPayload 構築(CLIErrorReporting.swift:35/58/115・CLIHelpers.swift:325)と一部テストが pace 無しで生成しており、同じ init の既存 cacheAccountKey: String? = nil と同じ慣習です。外すと各呼び出しで pace: nil 明示が必要になり、cacheAccountKey と不揃いになります。

Comment thread Sources/CodexBarCLI/CLIPayloads.swift Outdated
}

struct PacePayload: Encodable {
/// Raw `UsagePace.Stage`: onTrack / slightlyAhead / ahead / farAhead / slightlyBehind / behind / farBehind.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

並び順が変。farAheadからfarBehindまで順番に並べて

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0065cac8stageString を farAhead → ahead → slightlyAhead → onTrack → slightlyBehind → behind → farBehind のスペクトル順に並べ替えました。コメントが付いていた doc 行(/// Raw UsagePace.Stage: …)はコメント削減で除去済みのため、実コードの列挙である stageString を対象にしています。

@kmatsunami kmatsunami left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review @ 44a1afc — all feedback addressed ✅

Verified locally: swift test --filter CLISnapshotTests34 passing, build clean, removed field has no orphan references. Walking the prior comments:

Prior finding Status
Weekly diverges from menu (no work-days / no Codex historical) ✅ Documented honestly — PaceKind now states .session mirrors the GUI exactly while .weekly is "only a plain linear approximation … so weekly stage/delta/eta can differ from the menu bar." The behavioral gap is inherent (the CLI has no access to the GUI's account-scoped historical dataset or live weeklyProgressWorkDays), so documenting it precisely is the right call.
Weekly provider coverage narrower than menu ✅ Same doc now calls out the "fixed provider allowlist rather than the menu's any window with windowMinutes rule."
runOutProbability is a dead field ✅ Doc added: "Always absent in CLI output … Kept for schema parity." Exactly the note I suggested.
deltaPercent inconsistent after rounding ✅ Fixed properly — actualUsedPercent is removed from the payload (it duplicated usage.<window>.usedPercent), so there's no longer a derivable field that disagrees with its parts. Tests updated to assert the key is omitted.
Timing-coupled text tests ✅ Fixed — renderText now takes now: Date = Date(), and the three text pace tests thread now: now so the snapshot clock and render clock match. Deterministic now.
Boundary coverage gap ✅ New test renders session pace on track and lasts until reset covers both the onTrack ("On pace", delta 0) boundary and the willLastToReset → "Lasts until reset" branch.
Claude 7-day fallback shown as "Session" ⚪ Left as-is — intentional, mirrors UsagePaceText.sessionSummary(primary). Fine.

LGTM. (Can't formally Approve my own PR, hence the comment.)

One tiny, non-blocking leftover for whenever: the eta-rounds-to-"now" wording (Projected empty now / Runs out now) is still the only pace branch without a test. And the larger refactor — lifting the shared session/weekly pace + label helpers + the 300/10080/3 constants into CodexBarCore, and making UsagePace.Stage String-backed — remains a good follow-up to keep CLI and menu from drifting, but is rightly out of scope for this PR.

@kmatsunami
kmatsunami force-pushed the feat/cli-session-and-json-pace branch 2 times, most recently from f24122a to bbb0b4a Compare June 23, 2026 12:13
@kmatsunami kmatsunami changed the title feat(cli): add Session pace and JSON pace output feat(cli): show Session pace and add pace to JSON output Jun 23, 2026
@kmatsunami
kmatsunami force-pushed the feat/cli-session-and-json-pace branch 2 times, most recently from 0065cac to c9074b3 Compare June 23, 2026 12:58
`codexbar usage` already shows a Pace line for the Weekly window but not for
Session, and `--format json` emits no pace at all. This fills both gaps so
pace coverage is consistent across windows and output formats.

- Text: add a Pace line under "Session" (previously Weekly-only), using the
  GUI's session-pace calculation (5-hour window; codex/claude/ollama) and its
  "Projected empty in …" wording; Weekly keeps "Runs out in …".
- JSON: add a derived `pace` object — `pace.primary` (session) and
  `pace.secondary` (weekly) — with stage, deltaPercent, expectedUsedPercent,
  willLastToReset, optional etaSeconds, and a human-readable summary. Numbers
  are rounded to whole values to match the existing `usage` block; a key is
  emitted only when the corresponding text Pace line would render.

Session pace applies only to real (≤5h) session windows, so a 7-day window
that some providers fall back into `primary` (e.g. Claude with no 5-hour data)
is not mislabeled as a session. Weekly remains a plain linear approximation
(no workDays / Codex historical refinement), unlike the menu's
UsageStore.weeklyPace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kmatsunami
kmatsunami force-pushed the feat/cli-session-and-json-pace branch from c9074b3 to 84f35ce Compare June 23, 2026 13:12
@kmatsunami kmatsunami added enhancement New feature or request area:cli-integration CLI usage/serve commands and JSON output labels Jun 23, 2026
steipete and others added 12 commits June 23, 2026 16:19
The CLI weekly pace line was always linear (elapsed/duration), so it
diverged from the menu bar whenever the user had configured a work-day
count in Preferences > Display.  The mismatch made the same number look
like a larger deficit in the CLI than in the GUI.

Root cause: `UsagePace.weekly()` already supports a `workDays:` parameter
that narrows the baseline to elapsed/total work-day time only, but the
CLI called it without `workDays` (nil → linear).

Fix: read `weeklyProgressWorkDays` from the GUI app's UserDefaults
(domains `com.steipete.codexbar` / `.debug`, fallback to standard) using
the same lookup pattern as `resetTimeDisplayStyleFromDefaults()`.  Pass
the value through `UsageCommandContext → RenderContext / providerPacePayload
→ computePace`, where it is forwarded to `UsagePace.weekly` only for the
`.weekly` pace kind (session/5h ignores it, as the workday correction
in `UsagePace.weekly` requires minutes == 10080).

When no setting is stored (Off / not configured) `weeklyWorkDays` is nil
and the behaviour is unchanged (linear baseline, same as before).

All 35 CLISnapshotTests pass.
* Fix remaining memory pressure source isolation crash

* docs: note memory pressure crash fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
ProviderVersionDetector.claudeVersion() invoked the CLI as
`claude --allowed-tools "" --version`. The `--allowed-tools` flag makes
Claude Code treat the call as a real session and initialize its
credential subsystem, reading the OAuth token from the macOS keychain
item "Claude Code-credentials" via `/usr/bin/security`. When no
`~/.claude/.credentials.json` file and no `CLAUDE_CODE_OAUTH_TOKEN` env
var exist, that keychain read pops a `security`-subject keychain prompt
on every version probe (i.e. every usage refresh), regardless of the
Claude usage source or CodexBar's keychain prompt policy.

Drop the `--allowed-tools ""` args so the probe runs plain
`claude --version`, which prints the version and exits before any
credential/keychain access (matching the codex/gemini detectors).

Verified: `claude --allowed-tools "" --version` -> version + 1 keychain
prompt; `claude --version` -> same version, 0 prompts.
Co-authored-by: kiranmagic7 <262980978+kiranmagic7@users.noreply.github.com>
* Fix stale today usage buckets

* Handle overlapping API usage buckets

* Avoid Admin API UTC bucket overcount

* fix: keep Poe Today usage local-day scoped

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
* Fix Antigravity quota summary lanes

* Align Antigravity icon lanes with quota summary ranking

* fix: align Antigravity ranking with rendered lanes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli-integration CLI usage/serve commands and JSON output enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants