Skip to content

fix(tui): provider color overrides across ranks + CLI / sessions / app.rs cleanup#440

Merged
junhoyeo merged 11 commits intomainfrom
fix/pr-436-followup
Apr 17, 2026
Merged

fix(tui): provider color overrides across ranks + CLI / sessions / app.rs cleanup#440
junhoyeo merged 11 commits intomainfrom
fix/pr-436-followup

Conversation

@junhoyeo
Copy link
Copy Markdown
Owner

@junhoyeo junhoyeo commented Apr 17, 2026

Summary

Follow-ups to #436 (cost-ranked shade palettes) plus adjacent cleanups surfaced during review.

PR #436 bug fixes (fix(tui))

  • [HIGH] [colors.providers] override was silently disabled for rank ≥ 1. get_provider_shade() now consults TokscaleConfig::get_provider_color() at every rank and derives a 7-step lighten-to-white palette (shade_from_base) from the override base when present. A user setting anthropic = \"#FF0000\" now sees red-family shades across all their Anthropic models.
  • [MEDIUM] Shade collisions under GroupBy::WorkspaceModel. Same model appearing in N workspaces used to consume N rank slots and then collide on insert. Now aggregated to (provider, model_name) -> total_cost before ranking.
  • [MEDIUM] Non-deterministic order on cost ties caused shade flicker across refreshes. Secondary sort by model name makes assignment stable.
  • [LOW] CURSOR_SHADES expanded from 1 → 7 entries; unknown-provider fallback replaced by a gray ramp (was collapsing every unknown-provider model to pure white).

13 new tests cover: rank-0-to-highest-cost, workspace dedupe, cost-tie determinism, NaN-cost handling, cross-provider independence, rebuild-on-update, plus shade_from_base edge cases and unknown/cursor palette regressions.

CLI refactor (refactor(cli))

19 client-filter bools + 6 date-range options were redeclared across 7 clap structs — ~145 duplicated #[arg(...)] lines plus 19-field ClientFlags { ... } rebuilds at every call site. The existing ClientFlags runtime helper becomes a #[derive(Args)] struct; a parallel DateRangeFlags captures the date block. Both are #[command(flatten)]ed into Cli, Models, Monthly, Hourly, Graph, Tui, and Submit.

main.rs: 5093 → 4445 (-648 lines, -12.7%). No CLI surface change — all --opencode, --claude, --today, --since, etc. still parse identically. One intentional UX win: Submit's inconsistent "Include only X data" help text now matches the uniform "Show only X usage" used elsewhere.

Sessions + TUI cleanup (refactor(sessions), refactor(tui))

An architect review rejected broader refactors (SessionParser trait, full app.rs split) as over-engineering, but flagged two narrow wins that landed:

  • 11 session parsers now use shared open_readonly_sqlite + read_file_or_none helpers in sessions/utils.rs. No behavior change — same read-only SQLite flags, same error-to-empty fallback.
  • build_model_shade_map and build_export_json moved out of App into tui/colors.rs and tui/export.rs as pure functions. app.rs 2247 → 2174.

Test plan

  • cargo clippy --workspace --all-features --all-targets -- -D warnings — clean
  • cargo fmt --all -- --check — clean
  • cargo test --workspace --all-features — 950 passing (345 + 83 + 519 + 3), no regressions
  • tokscale models --help spot-check: all client/date flags preserved
  • tokscale submit --help: unified "Show only X usage" help text
  • Manual TUI verification with a ~/.tokscale override for anthropic (would confirm HIGH bug fix end-to-end)

Open with Devin

Summary by cubic

Fixes TUI shade palette bugs, makes model colors provider-aware across all views and reloads, and removes duplicated CLI/session code. No CLI surface changes; TUI colors now respect provider overrides, handle empty providers, and keep labels/colors when loading legacy caches.

  • Bug Fixes

    • [colors.providers] overrides now apply at every rank via a 7-step palette (rank 0 = base).
    • Shade map is keyed and ranked by aggregated (provider, model) cost; ties sort by model name; NaN treated as 0. Duplicate model names across providers no longer collide.
    • Models, Overview, and Stats use provider-aware color lookup. Hourly data and cache retain provider/displayName/colorKey (backward-compatible) so colors persist across reloads.
    • Empty or merged provider IDs are normalized during shade-map build and lookup to prevent mismatches and rank-0 fallbacks.
    • Legacy hourly cache entries missing display fields now fall back to the map key to rebuild displayName/colorKey, preserving labels and colors.
    • cursor palette expanded to 7 shades; unknown providers use a neutral gray ramp. Tests cover ranking, dedupe, ties, NaN handling, provider separation, rebuilds, palette overrides, and legacy cache fallback.
  • Refactors

    • CLI: Extracted ClientFlags and DateRangeFlags as clap Args, flattened into Cli, Models, Monthly, Hourly, Graph, Tui, Submit, and Wrapped. ~648 lines removed; flags unchanged; help text unified to “Show only X usage”.
    • Sessions (@tokscale-core): Added open_readonly_sqlite and read_file_or_none; applied across 11 parsers. No behavior change.
    • TUI (@tokscale-cli): Moved shade-map builder to tui/colors.rs and export JSON builder to tui/export.rs as pure functions.

Written for commit 4b2e2a3. Summary will update on new commits.

…ade map

PR #436 introduced cost-ranked shade palettes but shipped four issues:

1. [HIGH] Provider color override silently disabled for rank ≥ 1.
   build_model_shade_map called get_provider_shade() which never
   consulted TokscaleConfig::get_provider_color(). A user setting
   [colors.providers] anthropic = "#FF0000" only saw red on their
   top-cost Anthropic model; ranks 1-6 kept the hardcoded coral family.
   Fix: check config override inside get_provider_shade; when present,
   derive a 7-step lighten-to-white palette from the override base via
   shade_from_base(). Rank 0 is the override color exactly; later ranks
   interpolate toward white with factors that match the end-of-ramp
   lightness (≈0.67) of the hardcoded palettes.

2. [MEDIUM] Shade collisions under GroupBy::WorkspaceModel.
   Same model appearing in N workspaces was counted N times during
   rank assignment, inflating ranks and making insert() last-write-win
   on the shared model name key. Fix: aggregate cost per (provider,
   model_name) before ranking; NaN costs are coerced to 0 for safety.

3. [MEDIUM] Non-deterministic ordering on cost ties.
   total_cmp + HashMap iteration produced unstable shade assignments
   when two models had equal cost (common during early data or zero-
   cost models). Fix: secondary sort by model name, so ties always
   resolve identically across refreshes.

4. [LOW] CURSOR_SHADES had 1 entry (vs 7 for others) and the unknown-
   provider fallback collapsed all models to pure white. Fix: generate
   7-step Cursor purple ramp; add UNKNOWN_SHADES neutral gray ramp so
   unknown-provider models stay visually distinguishable.

Also simplifies get_model_color() now that override handling lives in
get_provider_shade() — removes the duplicate config lookup.

Tests cover: rank-0-to-highest-cost, workspace dedupe, cost-tie
determinism, NaN cost handling, cross-provider independence, rebuild
on update_data, plus shade_from_base edge cases and unknown/cursor
palette regressions.

Constraint: hardcoded palettes are hand-picked from colorhexa and must not change for default providers
Constraint: TokscaleConfig::load() returns &'static via OnceLock, so calling it per-rank is free
Rejected: regenerate all palettes algorithmically from a single base color | would change default visual output for existing users
Rejected: parameterize get_provider_shade() with &TokscaleConfig | call sites span UI render paths; OnceLock cache makes it unnecessary
Confidence: high
Scope-risk: narrow
Directive: when adding a new provider palette, also add a case to get_provider_from_model — get_provider_shade defaults to UNKNOWN_SHADES for unmatched providers
Not-tested: live TokscaleConfig override round-trip (would require touching ~/.tokscale); relies on unit coverage of shade_from_base + integration via get_provider_shade
main.rs had 19 client-filter bools + 6 date-range options redeclared
across 7 command structs — roughly 145 duplicated clap #[arg(...)] lines
plus 19-field struct-literal rebuilds at every call site. The existing
`ClientFlags` helper already named those fields, so this turns it into
a #[derive(Args)] struct and adds a parallel `DateRangeFlags` for the
date block, then #[command(flatten)]s both into each command that
needs them.

- `Cli` root, `Models`, `Monthly`, `Hourly`, `Graph`, `Tui`, `Submit`
  now flatten both structs.
- `Wrapped` flattens only `ClientFlags` as `client_flags` (its own
  `--year`/`--clients` flags have different semantics).
- Match-arm destructures collapse from ~25 field names per arm to
  `{ clients, date, ... }`, and the 19-field `ClientFlags { ... }`
  rebuilds at call sites are gone — `build_client_filter(clients)`
  takes the flattened struct directly.
- `build_client_filter` / `build_date_filter` / `normalize_year_filter`
  signatures unchanged; no runtime behavior change.
- 19 existing tests that construct `ClientFlags` literally keep working
  because the public fields are identical.

UX: Submit's 13 flags previously read "Include only X data" while the
other six read "Show only X usage"; after flatten all 19 read "Show
only X usage" on every command. That's an intentional consistency fix
rather than a regression.

Net: main.rs 5093 → 4445 lines (-648).

Constraint: CLI surface must remain identical — all `--<client>` / `--today` / `--since` etc. still parse on the same subcommands
Rejected: a single `FilterFlags` carrying both clients and dates | Wrapped has no date block, so separate structs avoid forcing an awkward Option<DateRangeFlags>
Rejected: keep `#[arg(help = ...)]` variations per command | serves no user need; unified help matches the actual single code path
Confidence: high
Scope-risk: narrow
Directive: when adding a new client, edit `ClientFlags` + `build_client_filter` in main.rs AND `ClientId` in tokscale-core — the struct mirrors `ClientId` on purpose
Not-tested: every command's --help text rendered by clap (spot-checked `models`, `submit`, root); relies on existing CLI integration suite (83 passing)
11 parsers repeated the same two I/O boilerplate patterns:
- Connection::open_with_flags(path, READ_ONLY | NO_MUTEX)
- match fs::read(path) { Ok(c) => c, Err(_) => return Vec::new() }

Consolidated both into `sessions/utils.rs` as:
- `open_readonly_sqlite(&Path) -> Option<Connection>`
- `read_file_or_none(&Path) -> Option<Vec<u8>>`

Touched: amp, claudecode, crush, droid, gemini, kilo, mux, openclaw,
opencode, roocode, synthetic. No behavior change — helpers wrap the
same underlying calls and return None on the same errors the caller
previously matched into `Vec::new()`/early-return.

An earlier assessment rejected the broader `SessionParser` trait
refactor — parser signatures diverge too much (Codex's incremental
state, Claude's parent-subagent cache, Kilo's fallback timestamp,
Cursor's CSV schema auto-detect) for a trait to be anything but a
shoehorn. This helper extraction is the portion that was actually
shared.

Opencode.rs gets `#[cfg(test)] use rusqlite::Connection;` since
production code no longer needs the type directly but tests still
construct DBs for fixtures.

Constraint: tokscale-core already depends on rusqlite — no new dep added
Rejected: full SessionParser trait | parsers share <10% of their body; dispatch in lib.rs is per-client by design
Rejected: helper that also derives session_id from path stem | amp/openclaw/codex use three subtly different fallbacks; unify later
Confidence: high
Scope-risk: narrow
Directive: when adding a new SQLite-backed parser, call open_readonly_sqlite to preserve the READ_ONLY|NO_MUTEX invariant rather than opening the DB with write flags
Not-tested: failure paths on read-only volumes; the helpers pass through rusqlite/std errors identically so existing parser tests cover the happy path
app.rs was ~2247 lines, half tests half 32-field TUI state machine.
An architect review rejected a full App-struct split (handle_key_event
touches 17 fields — the coupling is essential to a TUI reducer) but
approved moving two genuinely-pure computations out:

- `build_model_shade_map(&[ModelUsage]) -> HashMap<String, Color>`
  moves to `tui/colors.rs`. Doesn't need any App state beyond `data.models`.
- `build_export_json(&UsageData) -> Result<String>` moves to
  `tui/export.rs`. File I/O and the status-message side effect stay
  on `App` where they belong.

`App::build_model_shade_map` and `App::export_to_json` keep the same
signatures as thin call-site wrappers so nothing external shifts. The
existing shade-map unit tests still go through `App::build_model_shade_map`
and are unchanged — though they could now target the pure function
directly if a future pass wants to drop the `App` setup.

Net: app.rs 2247 -> 2174 (-73). Not huge, but the two functions that
moved are the ones that actually wanted to be pure — the state-machine
methods that remain are where they belong.

Constraint: don't alter CLI/TUI behavior — only relocate pure logic
Rejected: full split into event.rs / sort.rs / dialog.rs | handle_key_event touches 17 fields; splitting just moves the tangle through more indirection
Rejected: move tests to sibling file via `#[path]` | cosmetic, doesn't improve anything
Confidence: high
Scope-risk: narrow
Directive: when adding new pure computations on UsageData, consider tui/colors.rs or tui/export.rs before expanding App's impl block
Not-tested: JSON output byte-equivalence (serde_json::to_string_pretty is used identically); relies on downstream tooling that reads the export to flag regressions
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
tokscale Ignored Ignored Preview Apr 17, 2026 7:45am

Request Review

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@junhoyeo junhoyeo changed the title fix(tui): PR #436 review follow-ups + CLI / sessions / app.rs cleanup fix(tui): provider color overrides across ranks + CLI / sessions / app.rs cleanup Apr 17, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 18 files

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

junhoyeo and others added 4 commits April 17, 2026 15:23
Store TUI shade entries by provider and model so duplicate model names stop overwriting each other across providers.

Constraint: Existing non-provider-specific views must keep their model-name fallback behavior
Rejected: Key the map by model name only | collapses ClientProviderModel rows with the same normalized model
Confidence: high
Scope-risk: narrow
Carry provider, display name, and color lookup metadata through aggregated and cached hourly data so provider-specific colors survive reloads and derived views.

Constraint: Cached data must remain backward-compatible when older entries are missing the new fields
Rejected: Recompute provider metadata only at render time | hourly and cached views do not preserve enough source context for a reliable rebuild
Confidence: high
Scope-risk: moderate
Update the model, overview, and stats screens to use provider-aware color lookups so duplicate model IDs render consistently across TUI surfaces.

Constraint: Model and workspace views still need graceful fallback when provider metadata is merged or unavailable
Rejected: Leave legacy model-only lookups in place for some screens | would keep the bug visible in overview and stats despite the underlying data fix
Confidence: high
Scope-risk: narrow
devin-ai-integration[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

junhoyeo and others added 3 commits April 17, 2026 16:44
Treat empty provider IDs the same way as merged provider lists so shade-map construction stays consistent with lookup normalization.

Constraint: Missing provider metadata must still preserve ranked color lookup for inferred providers
Rejected: Leave empty providers as-is | creates build-vs-lookup key mismatches and forces rank-0 fallback colors
Confidence: high
Scope-risk: narrow
Use the hourly model map key as the fallback source for legacy cache entries so display names and color keys survive deserialization when older cache files omit the new metadata.

Constraint: Cache loading must remain backward-compatible across legacy schema variants
Rejected: Fallback from display_name to color_key only | leaves both fields empty for older hourly cache entries
Confidence: high
Scope-risk: narrow
@junhoyeo junhoyeo merged commit 506a213 into main Apr 17, 2026
6 checks passed
@junhoyeo junhoyeo deleted the fix/pr-436-followup branch April 17, 2026 07:51
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