feat(tui): add Projects tab with per-project token breakdown#185
Conversation
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
left a comment
There was a problem hiding this comment.
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
|
Thanks for the thorough review! Addressed #1 (blocking) in ea08855. One thing worth flagging:
Net result: #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
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>
|
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: 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. 🙏✨ |
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 keys1–5).parent/folder(e.g.Scripts/toktrack).Enteron a project opens its daily/weekly/monthly view (reusing the existing source-detail view);Enteron a day opens the existing per-model breakdown popup.Escreturns 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:
cwd.cwdonturn_context.payload(withworkspace_roots), and sometimes onsession_meta.session.directory(joined viasession_id); falls back gracefully on older schemas without the column.cwdon the leadingsessionline.cwdfrom a.project_rootsidecar next to the session'schats/dir (the chat log itself carries only a hashedprojectHash).~/.qwen/projects/<cwd>/chats/…, no longer~/.qwen/tmp/*/chats/session-*.jsonl) and its token format (usage now lives intype:"system"/ui_telemetryqwen-code.api_responseevents, 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
UsageEntrygains an optionalproject;DailySummarygains a nestedprojects: HashMap<String, ProjectUsage>(token totals + cost + count + a per-model map) so the breakdown is stored, not recomputed.claude.rsreads the per-linecwd;codex.rsreadsturn_context/session_metacwd;pi_agent.rsreads the session-linecwd;opencode.rsjoinssession.directory;gemini.rsresolves the.project_rootsidecar. Sources without a project setproject: None.daily()populates the per-project / per-model breakdown; merged throughweekly()/monthly()/merge_by_date(). New helpers:by_project_from_daily()(the list) andproject_daily_summaries()(explodes summaries into per-project daily series for the drill-down).CACHE_VERSION13 → 14. A version mismatch keeps existing history (no wipe) and triggers a full reparse that backfillsprojectsfor 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 emptyprojectsmap (rawcwdgone), which is unavoidable.Testing
cargo fmt --all -- --check,cargo clippy --all-targets --all-features -- -D warnings, andcargo testall pass.cwd→ project extraction for Claude Code, Codex, OpenCode, PI Agent, and Gemini (plus no-cwd →Nonepaths); 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).https://claude.ai/code/session_015Hunjgo5bjbvCfbtCeXMUq