Skip to content

feat: Usage page redesign with CLI fallback + API billing support - #44

Merged
zhnd merged 3 commits into
mainfrom
feat/cli-usage-fallback
Apr 13, 2026
Merged

feat: Usage page redesign with CLI fallback + API billing support#44
zhnd merged 3 commits into
mainfrom
feat/cli-usage-fallback

Conversation

@zhnd

@zhnd zhnd commented Apr 13, 2026

Copy link
Copy Markdown
Owner

Summary

Complete rewrite of the Usage page to work reliably across all account types:

  • CLI-first probe using portable-pty + vt100 to run claude /usage inside a PTY and parse the output. No Keychain access on the happy path — claude itself reads the stored credentials.
  • OAuth API fallback only kicks in when the CLI probe fails (binary missing, PTY spawn failure, etc.).
  • ECharts gauge redesign: circular gauges showing percent remaining (fuel-gauge metaphor) with status-colored progress fill and inverted color bands. Replaces the old horizontal bars.
  • API billing support: pay-as-you-go accounts (including third-party gateways) now see a dedicated "Pay-as-you-go account" notice instead of the misleading "Claude Code login required" message.
  • Untouched buckets: Sonnet/Opus that the user hasn't touched yet still render the gauge at 100% remaining but show "You haven't used Sonnet yet" where the reset text would normally sit.

Backend changes

  • interactive_runner.rs — generic PTY runner with auto-response prompts, idle timeout, and has_meaningful_content detection (ported from ClaudeBar's InteractiveRunner.swift).
  • terminal_renderer.rs — thin wrapper around vt100::Parser for ANSI → plain text rendering.
  • claude_cli_probe.rs — Claude-specific parser with tri-state SectionPercent::{NotFound, Untouched, Used(f64)}. Per-bucket reset extraction bounded by the Extra usage section so inline "... Resets May 1" fragments can't bleed backwards. Detects API billing via the subscription badge and returns usage: None so the frontend gets a clean signal.
  • ExtraUsage.resets_at added to the typeshared struct so CLI-parsed extra usage reset lines flow to the UI.
  • subscription_usage_service.rs — CLI-first, OAuth API as fallback; propagates cli.usage: Option<...> directly.

Frontend changes

  • usage-category-card/ — gauge card using ECharts type: gauge with progress fill + color bands + centered percent. types.ts / libs.ts / use-service.ts / index.tsx split per project convention.
  • extra-usage-card/ — matching gauge for the pay-as-you-go overage section.
  • api-billing-notice/ — new empty state for API billing accounts (provider-neutral copy so third-party gateways are covered).
  • use-service.ts — new "api_billing" FetchStatus branch.
  • index.tsx — single responsive grid (sm:grid-cols-2), section headers dropped in favor of per-card labels, container widened to max-w-3xl.

Testing

  • 12 claude_cli_probe unit tests, including real_cli_output_produces_expected_buckets which pins the exact CLI rendering captured from a real Max account (Opus absent, Sonnet at 0% used no-bar → untouched).
  • cargo clippy --workspace -- -D warnings passes.
  • pnpm build (Next.js static export) passes.

Test plan

  • pnpm tauri:dev — Usage page loads with gauges on a Max account
  • Release build — no Keychain prompt on happy path; CLI probe runs directly
  • API billing account — "Pay-as-you-go account" notice renders (no misleading login prompt)
  • Untouched Sonnet — card shows full-ring gauge with "You haven't used Sonnet yet" below
  • Narrow window → 1 col, wider → 2 cols (responsive grid works)
  • macOS "Reduce Motion" — gauge animations are suppressed

🤖 Generated with Claude Code

zhnd and others added 3 commits April 12, 2026 23:09
Lumo's usage page previously failed completely whenever Anthropic's
OAuth usage API returned 403 (edge-layer rejection, likely TLS
fingerprinting of rustls). The existing CLI fallback was broken: it
used a non-existent --output json flag and had no PTY, but Claude Code
CLI is a React/Ink TUI that requires a terminal to function.

This ports ClaudeBar's dual-probe architecture to Rust:

- interactive_runner.rs: generic PTY runner via portable-pty. Spawns a
  child process in a 50x160 PTY, writes input, auto-responds to known
  prompts (trust dialog, "Press Enter to continue", etc.), and exits
  on child exit, hard timeout, or idle timeout after meaningful data.
- terminal_renderer.rs: thin wrapper around vt100::Parser that replays
  the captured bytes through a headless terminal emulator and returns
  the rendered screen contents as plain text.
- claude_cli_probe.rs: Claude-specific probe. Runs `claude /usage
  --allowed-tools ""` with CLAUDE_CODE_OAUTH_TOKEN stripped, inside a
  stable working directory (~/Library/Application Support/Lumo/Probe
  on macOS, ~/.lumo/probe on Linux). Pre-writes the trust entry to
  ~/.claude.json to avoid the workspace trust dialog stalling the run.
  Parses the rendered text with ClaudeBar's regex patterns
  (([0-9]{1,3})\s*%\s*(used|left) plus section-label windows) into
  SubscriptionUsageResponse.

subscription_usage_service.rs now delegates fetch_via_cli() to
ClaudeCliProbe, removing the broken naive parser helpers. The existing
API-primary/CLI-fallback flow is preserved.

Adds three new dependencies: portable-pty 0.9, vt100 0.16, regex 1.
All new modules have unit tests for prompt detection and usage parsing
(19 total tests passing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous API-primary/CLI-fallback design always hit
load_credentials() first, which triggered a macOS Keychain
authorization prompt on every release build launch — unreasonable UX
when the CLI path can fetch the same data without any Keychain access.

Matches ClaudeBar's default behaviour: CLI is the primary probe, API
is the fallback that only runs if the CLI can't spawn (binary missing,
PTY failure, parse error, etc.).

To keep the subscription tier badge working without reading credentials
from Keychain, the CLI probe now also parses the account tier from the
rendered /usage header line ("Opus 4.5 · Claude Max" → "MAX", etc.)
and returns it alongside the usage buckets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Redesign the Usage page around circular ECharts gauges and add a real
CLI parser that correctly handles untouched sub-buckets and
pay-as-you-go API accounts.

Frontend
- Replace the horizontal progress-bar cards with ECharts `type: gauge`
  circular gauges (progress fill + inverted color bands so the "safe"
  zone is where a full ring points). Gauges show percent REMAINING
  instead of used, matching ClaudeBar's fuel-gauge metaphor.
- Card header now sits top-left for scan-ability; reset text is left-
  aligned below the gauge. Gauges are capped at 200px square and flow
  in a 1-col → 2-col responsive grid with no section headers — the
  card labels already disambiguate each bucket.
- Untouched buckets (e.g. Sonnet on a Max account where it hasn't been
  touched yet) still render the full gauge at 100% remaining, but the
  reset slot is swapped for "You haven't used Sonnet yet" when the
  backend reports `utilization == null`.
- New `ApiBillingNotice` empty state for pay-as-you-go / API billing
  accounts so they no longer see the misleading "Claude Code login
  required" message. Copy is provider-neutral ("Check your provider's
  dashboard") to cover third-party gateways.
- Extract `UsageStatusTheme`, `UsageStatus`, and `UseUsageBucketCardResult`
  into `usage-category-card/types.ts`; move `buildUsageStatusTheme` and
  `prefersReducedMotion` into `libs.ts`. `use-service.ts` is now just
  the hook body.

Backend
- `ExtraUsage` gains an optional `resets_at` field so CLI-parsed extra
  usage reset lines can flow through to the UI.
- `ClaudeCliProbe` now returns `CliProbeResult.usage: Option<...>`.
  If the CLI header reports "API Usage Billing", we short-circuit with
  `usage: None` so the frontend gets a clean signal instead of the
  probe bailing and falling through to the OAuth API path.
- Per-bucket reset extraction (no more shared weekly reset): each
  bucket extracts its own reset inside the section window bounded by
  the "Extra usage" section so inline "... Resets May 1" fragments
  can't bleed backwards into Sonnet / Opus.
- New tri-state `SectionPercent::{NotFound, Untouched, Used}` so the
  parser can distinguish "section absent" from "section present but
  the user hasn't touched this model yet" (CLI renders `0% used` with
  no block characters). Untouched buckets carry `utilization: None`
  all the way to the frontend.

Tests
- 12 `claude_cli_probe` unit tests including `real_cli_output_produces
  _expected_buckets` which pins the exact rendered CLI output from a
  Max account where Opus is missing and Sonnet is untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zhnd zhnd self-assigned this Apr 13, 2026
@zhnd
zhnd merged commit 6caaf67 into main Apr 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant