Skip to content

feat(tui): DateBreakdownPanel improvements, shared components, and branding - #19

Merged
junhoyeo merged 6 commits into
mainfrom
fix/breakdown-panel-no-border
Dec 17, 2025
Merged

feat(tui): DateBreakdownPanel improvements, shared components, and branding#19
junhoyeo merged 6 commits into
mainfrom
fix/breakdown-panel-no-border

Conversation

@junhoyeo

@junhoyeo junhoyeo commented Dec 16, 2025

Copy link
Copy Markdown
Owner

Summary

This PR improves the TUI DateBreakdownPanel, extracts shared components for consistency, fixes reactivity bugs, updates branding, and refactors for better code organization and type safety.

Changes

🐛 Bug Fixes

  • Fix DateBreakdownPanel crash: Removed borderStyle="round" which crashed OpenTUI on dynamically rendered components
  • Fix contribution graph theme not updating: Theme changes now properly update the contribution graph even when breakdown panel is active
    • Root cause: getCellStyle returned a plain object inside <For> callback, which SolidJS didn't track reactively
    • Fix: Split into isSelected() and getCellColor() functions called directly in JSX for proper reactivity
  • Fix date parsing timezone drift: Added T00:00:00 suffix to date strings to prevent off-by-one day/month bugs in negative UTC timezones

♻️ Refactoring

  • Extract shared ModelRow component: Unified model display with colored dot, name, percentage, and token breakdown
  • Extract shared TokenBreakdown component: Consistent In/Out/CR/CW display across views
  • Extract DateBreakdownPanel to separate file: Moved from StatsView.tsx to its own file (72 lines)
  • Consolidate SOURCE_COLORS to colors.ts: Centralized source color definitions with type-safe Record<SourceType, string>
  • Add getSourceColor() helper: Clean API for getting source colors with fallback
  • Refactor OverviewView and StatsView: Both now use shared components with identical styling
  • Cleanup unused props: Removed onlyNonZero, showCostInline, shortLabels options for simpler API
  • Remove debug code: Removed debugInfo signal, debug UI display, and debug comments from StatsView

✨ Features

  • Rename header to "tokscale": Changed from "Token Usage Tracker"
  • Add GitHub link: Header now shows "tokscale | GitHub" and clicking opens https://github.com/junhoyeo/tokscale
  • Cross-platform URL opening: Supports macOS (open), Linux (xdg-open), and Windows (start)
  • Sort models by usage in DateBreakdownPanel: Models within each source group now sorted by total tokens descending for UX consistency

🔒 Type Safety Improvements

  • SOURCE_COLORS now typed as Record<SourceType, string> for compile-time exhaustiveness
  • getSourceColor() accepts SourceType | string for flexibility while maintaining type safety

Files Changed

  • packages/cli/src/tui/components/Header.tsx - Branding and GitHub link
  • packages/cli/src/tui/components/StatsView.tsx - Reduced 316→225 lines, debug removed, shared components
  • packages/cli/src/tui/components/DateBreakdownPanel.tsx - New file (extracted component)
  • packages/cli/src/tui/components/OverviewView.tsx - Use shared ModelRow component
  • packages/cli/src/tui/components/ModelRow.tsx - New shared component
  • packages/cli/src/tui/components/TokenBreakdown.tsx - New shared component
  • packages/cli/src/tui/components/index.ts - Export new components
  • packages/cli/src/tui/utils/colors.ts - Added SOURCE_COLORS and getSourceColor()

Testing

  • Manually verified clicking dates no longer crashes
  • Verified theme switching works with breakdown panel active
  • Verified consistent styling between Overview and Stats views
  • Verified GitHub link opens correctly
  • TypeScript compilation passes (npx tsc --noEmit)
  • Oracle verification: PERFECT - NO IMPROVEMENTS NEEDED

borderStyle causes OpenTUI crash when used in dynamically rendered
components (inside Show). Removed border, kept all other features:
- Source grouping (OPENCODE, CLAUDE, etc.)
- Per-model breakdown with cost and tokens
- Input/Output/Cache token display
- Remove unused Setter import from StatsView
- Remove unused onDateSelect prop from StatsViewProps
- Remove unused palette prop from DateBreakdownPanelProps
- Add cacheWrite token display (CacheW) in breakdown
- Simplify footer (remove redundant total, already in header)
- Create ModelRow component for unified model display with colored dot, name, percentage, and token breakdown
- Create TokenBreakdown component for consistent In/Out/CR/CW display
- Refactor OverviewView and StatsView to use shared components
- Ensure Stats breakdown panel uses exact same styles as Overview's model-by-cost
…l is active

The issue was that getCellStyle was called once per cell inside a <For> callback,
returning a plain object. When colorPalette changed, SolidJS didn't track the
dependency because the callback was cached by <For>.

Fix: Replace getCellStyle with inline reactive function calls (isSelected, getCellColor)
that are evaluated directly in JSX attributes, ensuring proper reactivity tracking.
- Change 'Token Usage Tracker' to 'tokscale'
- Make header title clickable to open https://github.com/junhoyeo/tokscale
- Support macOS, Linux, and Windows for opening URLs
@junhoyeo junhoyeo changed the title fix(tui): restore full DateBreakdownPanel without borderStyle feat(tui): DateBreakdownPanel improvements, shared components, and branding Dec 17, 2025
…te parsing

- Extract DateBreakdownPanel component to separate file (72 lines)
- Move SOURCE_COLORS to utils/colors.ts with SourceType typing
- Add getSourceColor() helper function
- Fix date parsing timezone drift with T00:00:00 suffix
- Add model sorting by total tokens in DateBreakdownPanel
- Remove debug code (debugInfo signal, debug UI, comments)
- Update component barrel exports

StatsView.tsx reduced from 316 to 225 lines.
@junhoyeo
junhoyeo merged commit 0dde7eb into main Dec 17, 2025
2 checks passed
@junhoyeo
junhoyeo deleted the fix/breakdown-panel-no-border branch December 17, 2025 10:05
junhoyeo added a commit to haunchen/tokscale that referenced this pull request Jun 17, 2026
…arser tests

Map the raw junhoyeo#19 responseModel `gemini-3-flash-a` onto the priced
`gemini-3-flash-preview` so Antigravity CLI cost no longer resolves to 0.
Add alias-resolution, junhoyeo#9/junhoyeo#10==junhoyeo#3 field-mapping invariant, and
malformed-protobuf bounds tests.

Constraint: must not weaken the junhoyeo#707 brand-token fuzzy-match guard in lookup.rs

Confidence: high

Scope-risk: narrow
junhoyeo added a commit that referenced this pull request Jun 17, 2026
#713)

* feat(sessions): read Antigravity CLI usage from local SQLite databases

The Antigravity CLI (the terminal agent that stores its data under `~/.gemini/antigravity-cli/`) was never counted. tokscale only knew two Gemini-family sources: the Gemini CLI (scans `~/.gemini/tmp/*.{json,jsonl}`) and Antigravity (pulls usage from a running IDE language server over RPC and caches it under the config dir). The Antigravity CLI fell into neither bucket, so its on-disk usage was invisible — `tokscale antigravity sync` found the filesystem candidates but cached zero because its only artifact path still requires a live language-server RPC connection.

This adds Antigravity CLI as a first-class local scan source so its usage updates automatically like every other file-based source — no RPC, no `antigravity sync`. A new `antigravity-cli` client globs `~/.gemini/antigravity-cli/conversations/*.db` (honoring `GEMINI_CLI_HOME`) and a new parser reads each conversation database directly.

Each `gen_metadata` row is one generation encoded as the same `GeneratorMetadata` protobuf the IDE returns over `GetCascadeTrajectoryGeneratorMetadata`. The repository has no `.proto`/prost decoder (the IDE path receives JSON because the language server does the proto-to-JSON conversion), so the parser ships a tiny dependency-free wire-format reader and pulls only the fields it needs. The field numbers were reverse-engineered from real databases and cross-checked across 6 sessions / 140 turns: `chatModel.#19` is the response model, `usage.#5`/`#9`/`#10` are cacheRead/output/thinking (verified by the invariant `#9 + #10 == #3`, the stored total output), `#11` is the responseId used for dedup, and input combines the fixed system-prompt count `#1` with the newly-processed input `#2`. The session timestamp and workspace come from `trajectory_metadata_blob`.

Adding the new `ClientId` variant fans out to the usual registration points: the scanner gains a `*.db` glob arm (which naturally rejects `.db-wal`/`.db-shm` sidecars), both local-parse dispatch paths gain a branch, and the CLI `ClientFilter`, client labels, TUI picker, and frontend source maps gain entries. The deprecated per-client boolean flags intentionally do not, since `antigravity-cli` is reachable only via the canonical `--client antigravity-cli`.

Closes #712.

* fix(sessions): handle file:// authority/UNC paths and test Antigravity CLI wiring

Addresses the cubic review on #713.

`file_uri_to_path` previously stripped `file://` and only special-cased the leading slash before a Windows drive letter, so a non-empty authority (`file://host/share/...`, the UNC form) lost its host and collapsed into a bare path. It now treats an empty-authority remainder as before (`/C:/x` → `C:/x`, `/home/x` kept) and reconstructs a non-empty authority as a UNC path (`host/share/x` → `//host/share/x`) so `normalize_workspace_key` preserves the `//` prefix. A unit test covers the Windows-drive, POSIX, UNC, and percent-encoded-CJK cases.

The new `AntigravityCli` client wiring is now asserted in `test_client_as_str`, `test_client_key`, and `test_client_from_key` (display name "Antigravity CLI", hotkey `f`, and the reverse hotkey mapping).

* style: rustfmt antigravity_cli.rs

* fix(antigravity-cli): add gemini-3-flash-a pricing alias and harden parser tests

Map the raw #19 responseModel `gemini-3-flash-a` onto the priced
`gemini-3-flash-preview` so Antigravity CLI cost no longer resolves to 0.
Add alias-resolution, #9/#10==#3 field-mapping invariant, and
malformed-protobuf bounds tests.

Constraint: must not weaken the #707 brand-token fuzzy-match guard in lookup.rs

Confidence: high

Scope-risk: narrow

---------

Co-authored-by: Junho Yeo <i@junho.io>
pinion05 added a commit to pinion05/tokscale that referenced this pull request Jun 23, 2026
junhoyeo#713)

* feat(sessions): read Antigravity CLI usage from local SQLite databases

The Antigravity CLI (the terminal agent that stores its data under `~/.gemini/antigravity-cli/`) was never counted. tokscale only knew two Gemini-family sources: the Gemini CLI (scans `~/.gemini/tmp/*.{json,jsonl}`) and Antigravity (pulls usage from a running IDE language server over RPC and caches it under the config dir). The Antigravity CLI fell into neither bucket, so its on-disk usage was invisible — `tokscale antigravity sync` found the filesystem candidates but cached zero because its only artifact path still requires a live language-server RPC connection.

This adds Antigravity CLI as a first-class local scan source so its usage updates automatically like every other file-based source — no RPC, no `antigravity sync`. A new `antigravity-cli` client globs `~/.gemini/antigravity-cli/conversations/*.db` (honoring `GEMINI_CLI_HOME`) and a new parser reads each conversation database directly.

Each `gen_metadata` row is one generation encoded as the same `GeneratorMetadata` protobuf the IDE returns over `GetCascadeTrajectoryGeneratorMetadata`. The repository has no `.proto`/prost decoder (the IDE path receives JSON because the language server does the proto-to-JSON conversion), so the parser ships a tiny dependency-free wire-format reader and pulls only the fields it needs. The field numbers were reverse-engineered from real databases and cross-checked across 6 sessions / 140 turns: `chatModel.junhoyeo#19` is the response model, `usage.junhoyeo#5`/`junhoyeo#9`/`junhoyeo#10` are cacheRead/output/thinking (verified by the invariant `junhoyeo#9 + junhoyeo#10 == junhoyeo#3`, the stored total output), `junhoyeo#11` is the responseId used for dedup, and input combines the fixed system-prompt count `#1` with the newly-processed input `junhoyeo#2`. The session timestamp and workspace come from `trajectory_metadata_blob`.

Adding the new `ClientId` variant fans out to the usual registration points: the scanner gains a `*.db` glob arm (which naturally rejects `.db-wal`/`.db-shm` sidecars), both local-parse dispatch paths gain a branch, and the CLI `ClientFilter`, client labels, TUI picker, and frontend source maps gain entries. The deprecated per-client boolean flags intentionally do not, since `antigravity-cli` is reachable only via the canonical `--client antigravity-cli`.

Closes junhoyeo#712.

* fix(sessions): handle file:// authority/UNC paths and test Antigravity CLI wiring

Addresses the cubic review on junhoyeo#713.

`file_uri_to_path` previously stripped `file://` and only special-cased the leading slash before a Windows drive letter, so a non-empty authority (`file://host/share/...`, the UNC form) lost its host and collapsed into a bare path. It now treats an empty-authority remainder as before (`/C:/x` → `C:/x`, `/home/x` kept) and reconstructs a non-empty authority as a UNC path (`host/share/x` → `//host/share/x`) so `normalize_workspace_key` preserves the `//` prefix. A unit test covers the Windows-drive, POSIX, UNC, and percent-encoded-CJK cases.

The new `AntigravityCli` client wiring is now asserted in `test_client_as_str`, `test_client_key`, and `test_client_from_key` (display name "Antigravity CLI", hotkey `f`, and the reverse hotkey mapping).

* style: rustfmt antigravity_cli.rs

* fix(antigravity-cli): add gemini-3-flash-a pricing alias and harden parser tests

Map the raw junhoyeo#19 responseModel `gemini-3-flash-a` onto the priced
`gemini-3-flash-preview` so Antigravity CLI cost no longer resolves to 0.
Add alias-resolution, junhoyeo#9/junhoyeo#10==junhoyeo#3 field-mapping invariant, and
malformed-protobuf bounds tests.

Constraint: must not weaken the junhoyeo#707 brand-token fuzzy-match guard in lookup.rs

Confidence: high

Scope-risk: narrow

---------

Co-authored-by: Junho Yeo <i@junho.io>
t1000040 referenced this pull request in tmobi-internal/tokscale Jun 30, 2026
junhoyeo#713)

* feat(sessions): read Antigravity CLI usage from local SQLite databases

The Antigravity CLI (the terminal agent that stores its data under `~/.gemini/antigravity-cli/`) was never counted. tokscale only knew two Gemini-family sources: the Gemini CLI (scans `~/.gemini/tmp/*.{json,jsonl}`) and Antigravity (pulls usage from a running IDE language server over RPC and caches it under the config dir). The Antigravity CLI fell into neither bucket, so its on-disk usage was invisible — `tokscale antigravity sync` found the filesystem candidates but cached zero because its only artifact path still requires a live language-server RPC connection.

This adds Antigravity CLI as a first-class local scan source so its usage updates automatically like every other file-based source — no RPC, no `antigravity sync`. A new `antigravity-cli` client globs `~/.gemini/antigravity-cli/conversations/*.db` (honoring `GEMINI_CLI_HOME`) and a new parser reads each conversation database directly.

Each `gen_metadata` row is one generation encoded as the same `GeneratorMetadata` protobuf the IDE returns over `GetCascadeTrajectoryGeneratorMetadata`. The repository has no `.proto`/prost decoder (the IDE path receives JSON because the language server does the proto-to-JSON conversion), so the parser ships a tiny dependency-free wire-format reader and pulls only the fields it needs. The field numbers were reverse-engineered from real databases and cross-checked across 6 sessions / 140 turns: `chatModel.#19` is the response model, `usage.#5`/`#9`/`#10` are cacheRead/output/thinking (verified by the invariant `#9 + #10 == #3`, the stored total output), `#11` is the responseId used for dedup, and input combines the fixed system-prompt count `#1` with the newly-processed input `#2`. The session timestamp and workspace come from `trajectory_metadata_blob`.

Adding the new `ClientId` variant fans out to the usual registration points: the scanner gains a `*.db` glob arm (which naturally rejects `.db-wal`/`.db-shm` sidecars), both local-parse dispatch paths gain a branch, and the CLI `ClientFilter`, client labels, TUI picker, and frontend source maps gain entries. The deprecated per-client boolean flags intentionally do not, since `antigravity-cli` is reachable only via the canonical `--client antigravity-cli`.

Closes junhoyeo#712.

* fix(sessions): handle file:// authority/UNC paths and test Antigravity CLI wiring

Addresses the cubic review on junhoyeo#713.

`file_uri_to_path` previously stripped `file://` and only special-cased the leading slash before a Windows drive letter, so a non-empty authority (`file://host/share/...`, the UNC form) lost its host and collapsed into a bare path. It now treats an empty-authority remainder as before (`/C:/x` → `C:/x`, `/home/x` kept) and reconstructs a non-empty authority as a UNC path (`host/share/x` → `//host/share/x`) so `normalize_workspace_key` preserves the `//` prefix. A unit test covers the Windows-drive, POSIX, UNC, and percent-encoded-CJK cases.

The new `AntigravityCli` client wiring is now asserted in `test_client_as_str`, `test_client_key`, and `test_client_from_key` (display name "Antigravity CLI", hotkey `f`, and the reverse hotkey mapping).

* style: rustfmt antigravity_cli.rs

* fix(antigravity-cli): add gemini-3-flash-a pricing alias and harden parser tests

Map the raw #19 responseModel `gemini-3-flash-a` onto the priced
`gemini-3-flash-preview` so Antigravity CLI cost no longer resolves to 0.
Add alias-resolution, #9/#10==#3 field-mapping invariant, and
malformed-protobuf bounds tests.

Constraint: must not weaken the junhoyeo#707 brand-token fuzzy-match guard in lookup.rs

Confidence: high

Scope-risk: narrow

---------

Co-authored-by: Junho Yeo <i@junho.io>
haunchen added a commit to haunchen/tokscale that referenced this pull request Aug 5, 2026
… went unidentified

`SessionModels::sole_model` served rows carrying neither `junhoyeo#19` nor `junhoyeo#21` whenever the file named exactly one model. That counted named ids, not models: a conversation where one row carries a display label no row ever identifies ran at least two models while naming only one, and an unlabelled row there could be either. It inherited the single named id and billed a model switch under the wrong model.

Gate the fallback on every label in the file resolving to a machine id, so those rows stay `unknown` — the same rule the label path already followed, now applied to the unlabelled one.

Also mark `junhoyeo#21` optional in the field table. It was present on every row observed so far, but the parser has never required it and the `sole_model` path exists precisely for rows that carry neither field.
junhoyeo pushed a commit that referenced this pull request Aug 6, 2026
…1051)

parse_gen_metadata read the model from chatModel.#19 and fell back to the
literal string "unknown", which inferred_provider_from_model cannot place,
producing unpriceable antigravity/unknown rows. Antigravity CLI drops a
whole group of chatModel fields (1, 2, 8, 10, 16, 19) on some continuation
and tool turns, but #21 — the model display label — survives on every one,
and sibling rows carry the machine id next to the identical label.

Turns are now attributed by resolving that label against labels seen
elsewhere in the same database.

Rejected: fall back to the conversation's sole model whenever a row lacks
a label | a conversation containing a model switch would bill the wrong
model instead of staying unknown (cubic P1, fixed in 73d5937 by
withholding the fallback when any label is unresolved)
Confidence: high
Scope-risk: narrow
Directive: post-#1053 these rows no longer abort a submission — they are
excluded silently, so a regression here is invisible rather than loud
Not-tested: a database where the same display label maps to two different
machine ids
junhoyeo added a commit that referenced this pull request Aug 8, 2026
…lay labels in SessionModels (#1059)

* Refactor label resolution logic in antigravity_cli.rs

* Fix dereferencing in antigravity_cli.rs

* test(antigravity_cli): pin the aliased-label recovery and satisfy rustfmt

The alias comparison landed with a 122-column line and a whitespace-only line, so `cargo fmt --all -- --check` failed the Lint gate; no build leg ran on the branch to catch it because the first-time-contributor workflow gate held the test jobs.

Adds the regression test the change was missing. Two rows spell the same priced model `gemini-pro-default` and `gemini-pro-agent` under one display label, and a third row carries the label with no `#19`. Without the fix that third row resolves to `unknown` and `submit` aborts; the test asserts `gemini-3.1-pro`. Verified red-green: reverting the canonical comparison fails it with `left: "unknown"`.

Also switches the fully-qualified `crate::pricing::aliases::` call to the `pricing::aliases::` form already used elsewhere in this file, and corrects the `by_display` doc comment, which still described the pre-change invariant.

Constraint: display labels must never become pricing keys, so the fix compares alias targets rather than trusting the label
Rejected: storing the canonical id in by_display | changes the contract that the map returns an observed #19 machine id
Confidence: high
Scope-risk: narrow
Not-tested: three-way labels where two ids share an alias and a third does not

---------

Co-authored-by: Junho Yeo <i@junho.io>
junhoyeo pushed a commit that referenced this pull request Aug 18, 2026
…1131)

* fix(antigravity-cli): resolve routing-label rows from display labels

Antigravity CLI writes the generic routing label `gemini-default` as
`chatModel.responseModel` (#19) whenever the router picked the model, leaving
the concrete tier only in the sibling `#21` display label (e.g. "Gemini 3.5
Flash (Low)"). The parser preserved the label verbatim, so those rows were
excluded from `tokscale submit` as unpriced despite carrying a tier-identifying
display label. Across real databases this silently dropped ~218M tokens / $74
of Gemini usage.

Resolve such rows in three steps: use a concrete `#19` as before; otherwise
recover the machine id from a sibling row sharing the display label (the
existing `SessionModels` mechanism); otherwise map the display label itself via
a verified lookup table. A routing label is never treated as evidence of a
concrete model, so it no longer poisons the `by_display` ambiguity check when a
real sibling id exists. Rows that still resolve to nothing keep `gemini-default`
verbatim, so the submission-time exclusion (`is_generic_routing_label` in
lib.rs) continues to apply rather than guessing.

Display labels are server-supplied and could be renamed or localized, so only
labels verified against real user databases are mapped; anything unknown returns
None and the routing label is preserved. Fixes #1116.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): map "Gemini 3.5 Flash (Low)" to gemini-3.5-flash-extra-low

Align the display-label mapping with the existing alias table
(`model_placeholder_m187` → `gemini-3.5-flash-extra-low` in
`aliases.rs:85`) so IDE and CLI Low-tier usage resolves to the same
model id. The previous value (`gemini-3.5-flash`) split the tier across
two model rows. Prices are identical today, but per-tier rows would
diverge — the exact scenario `aliases.rs:76-79` was written to guard
against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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