Skip to content

feat(tui): add Projects tab with per-project token breakdown#185

Merged
mag123c merged 11 commits into
mag123c:mainfrom
CubityFirst:feat/projects-tab
Jun 20, 2026
Merged

feat(tui): add Projects tab with per-project token breakdown#185
mag123c merged 11 commits into
mag123c:mainfrom
CubityFirst:feat/projects-tab

Conversation

@CubityFirst

@CubityFirst CubityFirst commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new Projects tab so you can see token & cost usage per project, and drill into any project for its day-by-day, per-model breakdown.

Tab order is now: Overview · Stats · Models · Projects · Audit (number keys 15).

  • Projects tab — a selectable list of projects sorted by cost, each with tokens / cost / a usage bar. Labels are shown as parent/folder (e.g. Scripts/toktrack).
  • Drill-downEnter on a project opens its daily/weekly/monthly view (reusing the existing source-detail view); Enter on a day opens the existing per-model breakdown popup. Esc returns to the Projects tab.

Which CLIs are covered

Per-project attribution needs a working directory in the session data. Each below was verified against live local data:

  • Claude Code — ✅ per-line cwd.
  • Codex — ✅ cwd on turn_context.payload (with workspace_roots), and sometimes on session_meta.
  • OpenCode — ✅ session.directory (joined via session_id); falls back gracefully on older schemas without the column.
  • PI Agent — ✅ cwd on the leading session line.
  • Gemini CLI — ✅ cwd from a .project_root sidecar next to the session's chats/ dir (the chat log itself carries only a hashed projectHash).
  • Qwen Code — ❌ not supported. Qwen does record a cwd, but v0.18.3 changed both its storage layout (~/.qwen/projects/<cwd>/chats/…, no longer ~/.qwen/tmp/*/chats/session-*.jsonl) and its token format (usage now lives in type:"system" / ui_telemetry qwen-code.api_response events, not the Gemini-style {"type":"gemini","tokens":{…}} blocks). toktrack's Gemini-based Qwen parser therefore ingests zero usage for this version — a pre-existing breakage independent of this PR. Once Qwen ingestion is fixed (separate change), per-project would come almost for free since the cwd is present.

Usage from any source without a project is grouped under a single (no project) entry.

How it works

  • typesUsageEntry gains an optional project; DailySummary gains a nested projects: HashMap<String, ProjectUsage> (token totals + cost + count + a per-model map) so the breakdown is stored, not recomputed.
  • parsersclaude.rs reads the per-line cwd; codex.rs reads turn_context/session_meta cwd; pi_agent.rs reads the session-line cwd; opencode.rs joins session.directory; gemini.rs resolves the .project_root sidecar. Sources without a project set project: None.
  • aggregatordaily() populates the per-project / per-model breakdown; merged through weekly() / monthly() / merge_by_date(). New helpers: by_project_from_daily() (the list) and project_daily_summaries() (explodes summaries into per-project daily series for the drill-down).
  • cacheCACHE_VERSION 13 → 14. A version mismatch keeps existing history (no wipe) and triggers a full reparse that backfills projects for every day whose raw files still exist, then caches it (surviving the CLI's 30-day deletion). The warm path now also checks the version per source (is_version_current), so a stale source cache is re-parsed even when other sources are current — previously such a source was never backfilled. Days already cache-only before upgrading keep an empty projects map (raw cwd gone), which is unavoidable.

Testing

  • cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings, and cargo test all pass.
  • New tests cover: cwd → project extraction for Claude Code, Codex, OpenCode, PI Agent, and Gemini (plus no-cwd → None paths); per-project/per-model aggregation; weekly rollup; explode-into-per-project-summaries; the stale-version-source backfill fix; the Projects widget rendering; and the tab navigation + drill-down interaction. Tests use synthetic placeholder paths (no machine-specific paths).
  • Smoke-tested against real local data for Claude Code, Codex, OpenCode, PI Agent, and Gemini — cache rebuilt to v14 with days populated per project (nested per-model usage), each attributed to the correct working directory.

https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq

Adds a new "Projects" tab (Overview · Stats · Models · Projects · Audit)
showing token & cost usage per project, with drill-down into each
project's day-by-day, per-model breakdown.

- types: add `project` to UsageEntry and a nested `projects`
  (ProjectUsage) map on DailySummary so the breakdown is persisted in
  the cache
- parser: Claude Code now records the session `cwd` as the project;
  other CLIs do not expose a project, so their usage is bucketed as
  "(no project)"
- services: aggregate per-project / per-model usage; new
  by_project_from_daily() and project_daily_summaries() helpers; merged
  through weekly/monthly/merge_by_date
- cache: bump CACHE_VERSION 13 -> 14; existing history is preserved and
  on-disk days are recomputed so project data is backfilled, then
  survives the source CLI's 30-day deletion
- tui: selectable Projects list, reusing the detail view + model-
  breakdown popup for drill-down

NOTE: per-project tracking currently works for Claude Code only — it is
the only supported CLI that records a working directory in its sessions.

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
PI Agent session files carry a working directory on the leading
`session` line, so attribute each file's usage to that `cwd` as the
project — same treatment as Claude Code. Per-project tracking now covers
Claude Code and PI Agent; other CLIs remain under "(no project)".

NOTE: verified against the pi_agent test fixture only; NOT yet tested
against a live PI Agent session directory, so the real field
name/placement may differ.

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
Codex writes the working directory on `turn_context.payload.cwd` (with
`workspace_roots`), and sometimes on `session_meta`. Track it like the
model/provider and attach it to each session's usage as the project.
Per-project tracking now covers Claude Code, Codex, and PI Agent.

Added a fixture (codex/cwd-session.jsonl) mirroring a real sanitised
Codex line, plus tests for cwd extraction and the no-cwd path.

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
Ran PI Agent locally; its v3 session files carry top-level `cwd` on the
`session` line exactly as assumed. Confirmed the pi-agent cache now
populates per-project usage (with nested per-model breakdown). Drop the
"untested against live data" caveat for PI Agent.

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
OpenCode's SQLite schema stores the working directory on
`session.directory` (keyed by the message's session_id). LEFT JOIN it
into the message query and attribute each entry to that directory as the
project. Falls back to a directory-less query on older schemas that lack
the session table/column (project stays None), preserving compatibility.

Per-project tracking now covers Claude Code, Codex, OpenCode, and PI
Agent. Verified against live OpenCode data (G:/Scripts/test_opencode).

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
The warm path decided per source whether to parse only recent files
based solely on cache existence + date coverage, ignoring the cache
version. So a source whose cache predates a schema change (e.g. the new
per-project breakdown) was never backfilled as long as ANY other source
had a current-version cache (which keeps the global warm path active) —
its older days kept empty `projects` forever.

Gate the recent-only path on `is_version_current` as well: a present but
stale-version source cache now triggers a full re-parse, which
`load_or_compute` merges with existing cache-only days, so the schema is
backfilled for every date whose raw files still exist while preserved
>30-day history is retained.

Added a regression test seeding a stale (no-gap) cache alongside a
current source and asserting the stale source is re-parsed AND its
ancient cache-only day survives.

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
gemini-cli (and older qwen-code) store the session working directory in a
`.project_root` file next to the session's `chats/` dir; the chat log
itself only carries a hashed projectHash. Resolve that sidecar (walking
up a few parents to cover subagent files) and attach it as the project.
Verified against live Gemini CLI data.

Note: this does NOT cover Qwen Code v0.18.3, which moved to a different
layout (~/.qwen/projects/...) and token format and is not ingested at all.

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
Project-extraction tests embedded real-looking local paths (e.g.
G:\Scripts\..., /home/me/...) as literals. They were deterministic
(temp dirs / fixtures, not reading the environment), but the values
should not resemble any one machine. Replace with neutral placeholders
(/work/..., /srv/work/..., Z:\...).

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq

@mag123c mag123c left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Really solid PR — thanks for this. The graceful fallbacks across every parser, the history-preserving cache migration, and the reuse of SourceDetailView for the drill-down all fit the codebase well. Tests pass and clippy is clean locally. Requesting changes on one item (#1), the rest are non-blocking.

1. (blocking) daily/weekly/monthly --json now leaks raw absolute paths
DailySummary.projects is serialized without skip_serializing_if, so the JSON CLI output gains a new projects key containing raw working-directory paths (which often include the OS username). This is both an output-schema change and a privacy surprise for anyone piping/sharing JSON. The shareable report receipt is correctly unaffected, so let's keep --json consistent with that:

#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub projects: HashMap<String, ProjectUsage>,

(or, if exposing it is intentional, let's document it.)

2. (docs) README / empty-state copy omit Gemini
The README bullet lists per-project support as "Claude Code, Codex, OpenCode, and PI Agent", and the empty-state hint says "usage from Claude Code populates this view" — but the code (and your PR description) also support Gemini via the .project_root sidecar. Could you align the copy?

3. (nit, UX) display-label collisions
project_display_name shows only the last two segments, so ~/a/x/proj and ~/b/x/proj both render as x/proj. The keys stay distinct so the data is correct — just visually ambiguous. Not a blocker; maybe show the full path on the selected row later.

Heads-up (not a change request): bumping CACHE_VERSION 13→14 makes every source stale on first run after upgrade, triggering a one-time full reparse for all users. That's expected given the new field and history is preserved — just flagging it.

CI hasn't run here (looks like it's pending approval for a first-time contributor). Given the Windows path handling in project_display_name and the OpenCode SQLite JOIN, it'd be good to get the 3-OS matrix green before merge.

Addresses review item mag123c#1. `DailySummary.projects` is keyed by raw
working-directory paths (often including the OS username), and the
daily/weekly/monthly `--json` commands serialized them — an output-schema
change and a privacy leak when piping/sharing.

- Add `skip_serializing_if = "HashMap::is_empty"` so days with no project
  data don't even emit the key (suggested in review).
- That alone is insufficient: populated days would still leak, so the
  `--json` handlers now `redact_project_paths` (clear the map) before
  serializing. With both, `--json` carries no `projects` key at all —
  consistent with the `report` receipt.

The breakdown is still persisted in the (local, private) cache and shown
in the TUI; only the public JSON surface is redacted. Verified: daily/
weekly/monthly `--json` emit zero project keys while the cache retains
them. Added tests for the skip-when-empty serialization, round-trip of
non-empty maps (cache relies on it), and the CLI redaction.

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
@CubityFirst

CubityFirst commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! Addressed #1 (blocking) in ea08855.

One thing worth flagging: skip_serializing_if = "HashMap::is_empty" alone wasn't enough — daily/weekly/monthly --json serialize summaries that have populated projects for recent/backfilled days, so the raw paths would still leak (I confirmed ~141 path lines in daily --json before the fix). So I did both:

  • Added the skip_serializing_if attribute (drops the key when empty).
  • The --json handlers now redact_project_paths(...) (clear the map) before serializing.

Net result: daily/weekly/monthly --json carry no projects key at all (verified: 0), consistent with the report receipt — while the per-project data is still persisted in the local cache and shown in the TUI. Added tests for the skip-when-empty serialization, the non-empty round-trip the cache relies on, and the CLI redaction.

#2 (docs) is addressed in 5967220 — added Gemini to the README per-project bullet and made the empty-state hint generic ("supported CLIs") so it won't drift again.

On #3 (display-label collisions): I don't think this can be meaningfully addressed without a consensus on the desired behavior first — specifically how much of the path to show (full path always? full path only on the selected row? a middle-ellipsized form?), or whether it should be user-configurable. The keys are already distinct so the data is correct; it's purely a display-policy decision. Happy to implement whichever direction you'd prefer.

The README per-project bullet listed only "Claude Code, Codex, OpenCode,
and PI Agent" and the Projects empty-state hint said usage from "Claude
Code" populates the view — both omitted Gemini, which is supported via
the `.project_root` sidecar. Add Gemini to the README, and make the
empty-state hint generic ("supported CLIs") so it can't drift again.

Claude-Session: https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq
@CubityFirst CubityFirst requested a review from mag123c June 20, 2026 08:58
Mirror the per-project breakdown changes from README.md into the Korean
README and the npm package README (5 tabs, Projects tab description,
1-5 tab keys). Also add the missing Qwen Code entry to npm Multi-CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mag123c

mag123c commented Jun 20, 2026

Copy link
Copy Markdown
Owner

LGTM 🎉 Thanks for the quick turnaround on the review.

I pushed one follow-up commit to keep the localized docs in sync for this release: README.ko.md and npm/README.md now describe the Projects tab / per-project breakdown (and the npm Multi-CLI list regained the missing Qwen Code entry). This matters because our release-please flow auto-publishes to npm on merge, and docs:-only PRs don't cut a release — so the localized/npm READMEs had to ride along in this PR.

CI is green across macOS/Linux/Windows — merging.

And honestly — thank you for the idea itself. I hadn't pictured a per-project view, and now I get to use a whole new TUI tab on my own usage. Genuinely great contribution. 🙏✨

@mag123c mag123c merged commit f8316b7 into mag123c:main Jun 20, 2026
3 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.

2 participants