Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b21c049
feat: add cross-platform source foundation
Jun 9, 2026
05de02f
docs: add windows tray mvp design
Jun 9, 2026
68977d2
docs: add windows tray mvp implementation plan
Jun 9, 2026
649de0d
feat: add structured web dashboard startup
Jun 9, 2026
5c670ef
feat: scaffold tauri dashboard frontend
Jun 9, 2026
7949c21
feat: add tauri tray process shell
Jun 9, 2026
a3c1506
feat: add tray api health monitoring
Jun 9, 2026
5664a26
docs: finalize windows tray implementation notes
Jun 9, 2026
08ecddf
fix: harden windows tray build and monitoring
Jun 9, 2026
a6d5876
fix: keep tray dashboard health responsive
Jun 9, 2026
d655dae
fix: show dashboard loading and partial results
Jun 9, 2026
a33929e
fix: unblock windows dashboard stats loading
Jun 9, 2026
c5f8664
fix: clarify dashboard trend and cache metrics
Jun 9, 2026
a78fc68
fix: polish windows tray icon and launch behavior
Jun 9, 2026
2af2ec0
feat: refresh web dashboard layout
Jun 9, 2026
5fadf26
fix: open dashboard button in browser
Jun 9, 2026
e83e1f6
fix: support gemini cli jsonl sessions
Jun 9, 2026
de51eeb
fix: address windows tray p1 p2 review items
Jun 9, 2026
85997d8
fix: prevent duplicate windows tray instances
Jun 10, 2026
9b4166d
fix: cache dashboard range switching
Jun 10, 2026
fdbaddc
fix: serve dashboard root explicitly
Jun 11, 2026
1a8451b
fix: use calendar periods for dashboard stats
Jun 12, 2026
6d7de1b
fix: hide package update banner in desktop shell
Jun 12, 2026
3b03d38
fix: make tray python discovery robust on macos
Jun 15, 2026
4c296c1
fix: update official model pricing
Jun 15, 2026
24f75ba
test: fix windows stderr capture fixture
Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Most tools only answer this for Claude Code. cc-statistics answers it for all fo

### Prerequisites

- Python 3.8+
- Python 3.10+
- At least one of: Claude Code CLI, Gemini CLI, Codex CLI, or Cursor installed and used

### 3 steps
Expand Down Expand Up @@ -212,6 +212,23 @@ pipx install cc-statistics
brew install androidZzT/tap/cc-statistics
```

### Windows Tray Development Preview

A Windows tray MVP lives in `desktop/cc-stats-tauri/`. It is a Tauri shell that starts the Python web dashboard with `python -m cc_stats_web --no-browser --json`, shows a tray menu, and opens the existing dashboard UI. Statistics, source discovery, parsing, pricing, and API responses stay in Python.

This preview is for development builds. It does not replace the macOS Swift app, and it does not bundle Python, signing, automatic updates, or installer polish yet.

```bash
cd desktop/cc-stats-tauri
npm install
npm test
npm run build:web

cd src-tauri
cargo test
cargo check
```

---

## 📖 CLI Reference
Expand Down Expand Up @@ -244,6 +261,16 @@ All data is read from local files. Nothing is sent over the network.
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` |
| Git Changes | `git log --numstat` in project directory |

### Path Overrides (Cross-Platform / Testing)

Set these environment variables to read source data from custom locations. Use paths visible to the shell or environment where `cc-stats` runs.

| Variable | Purpose |
|----------|---------|
| `CC_STATS_CLAUDE_PROJECTS_DIR` | Claude Code project log directory |
| `CC_STATS_CODEX_HOME` | Codex home; `sessions/` is read below it |
| `CC_STATS_GEMINI_HOME` | Gemini home; `tmp/*/chats/` is read below it |

---

## Acknowledgments
Expand Down
29 changes: 28 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@

### 前置条件

- Python 3.8+
- Python 3.10+
- 已安装并使用过以下至少一种工具:Claude Code CLI、Gemini CLI、Codex CLI 或 Cursor

### 3 步搞定
Expand Down Expand Up @@ -212,6 +212,23 @@ pipx install cc-statistics
brew install androidZzT/tap/cc-statistics
```

### Windows 托盘开发预览

Windows 托盘 MVP 位于 `desktop/cc-stats-tauri/`。它是一个 Tauri 平台壳,启动 `python -m cc_stats_web --no-browser --json`,提供托盘菜单,并打开现有 Web 仪表盘。统计、数据源发现、解析、定价和 API 响应仍然由 Python 负责。

这个预览面向开发构建,不替代现有 macOS Swift 应用,也暂不包含 Python 打包、签名、自动更新或安装器打磨。

```bash
cd desktop/cc-stats-tauri
npm install
npm test
npm run build:web

cd src-tauri
cargo test
cargo check
```

---

## 📖 CLI 参考
Expand Down Expand Up @@ -244,6 +261,16 @@ cc-stats-app # 启动 macOS 状态栏应用
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` |
| Git 变更 | 项目目录的 `git log --numstat` |

### 路径覆盖(跨平台 / 测试)

设置以下环境变量可从自定义位置读取源数据。请使用运行 `cc-stats` 的 shell 或环境可访问的路径。

| 变量 | 作用 |
|------|------|
| `CC_STATS_CLAUDE_PROJECTS_DIR` | Claude Code 项目日志目录 |
| `CC_STATS_CODEX_HOME` | Codex home;读取其下的 `sessions/` |
| `CC_STATS_GEMINI_HOME` | Gemini home;读取其下的 `tmp/*/chats/` |

---

## 致谢
Expand Down
12 changes: 6 additions & 6 deletions cc_stats/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path

from .parser import Message, Session, ToolCall
from .pricing import is_claude_model, match_model_pricing
from .pricing import match_model_pricing

# 文件扩展名 → 语言映射
EXT_TO_LANG: dict[str, str] = {
Expand Down Expand Up @@ -291,10 +291,8 @@ def compute_cache_stats(
# savings = cache_read_tokens * (input_price - cache_read_price) / 1M
savings_usd = 0.0
for model, usage in token_by_model.items():
if not is_claude_model(model):
continue
pricing = match_model_pricing(model)
savings_per_million = pricing["input"] - pricing["cache_read"]
savings_per_million = max(pricing["input"] - pricing["cache_read"], 0.0)
savings_usd += usage.cache_read_input_tokens * savings_per_million / 1_000_000

# 按模型拆分命中率
Expand Down Expand Up @@ -368,6 +366,8 @@ def _collect_git_stats(
cwd=project_path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
)
if result.returncode != 0:
Expand Down Expand Up @@ -467,7 +467,7 @@ def classify_work_mode(user_message_count: int, total_added: int, total_removed:
return "Building"


def analyze_session(session: Session) -> SessionStats:
def analyze_session(session: Session, *, include_git: bool = True) -> SessionStats:
"""分析单个会话,返回统计结果"""
stats = SessionStats(
session_id=session.session_id,
Expand Down Expand Up @@ -721,7 +721,7 @@ def analyze_session(session: Session) -> SessionStats:
stats.lines_by_lang = dict(lang_stats)

# -------- 4b. Git 变更统计 --------
if stats.start_time and stats.end_time and session.project_path:
if include_git and stats.start_time and stats.end_time and session.project_path:
git = _collect_git_stats(
session.project_path, stats.start_time, stats.end_time
)
Expand Down
121 changes: 37 additions & 84 deletions cc_stats/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,19 @@
from . import __version__
from .analyzer import SessionStats, TokenUsage, analyze_session, merge_stats
from .formatter import format_skill_stats, format_stats
from .parser import (
_claude_session_entry_files,
find_codex_sessions,
find_codex_sessions_by_keyword,
find_gemini_sessions,
find_gemini_sessions_by_keyword,
find_sessions,
find_sessions_by_keyword,
parse_session_file,
from .parser import _claude_session_entry_files
from .sources import (
SourceKind,
collect_session_files,
collect_session_files_by_keyword,
list_projects,
parse_file,
)


def _parse_session(path: Path):
"""根据文件类型选择解析器"""
return parse_session_file(path)
return parse_file(path)


def _parse_time_arg(value: str, *, as_end_of_day: bool = False) -> datetime:
Expand Down Expand Up @@ -298,66 +296,31 @@ def _compare_projects(args) -> None:

def _list_projects() -> None:
"""列出所有已知项目(Claude + Codex + Gemini)"""
has_any = False

# Claude 项目
claude_projects = Path.home() / ".claude" / "projects"
if claude_projects.exists():
print("\n可用项目 (Claude Code):")
print("─" * 60)
for proj in sorted(claude_projects.iterdir()):
if not proj.is_dir():
continue
jsonl_files = _claude_session_entry_files(proj)
if not jsonl_files:
continue
display_name = _resolve_project_name(proj, jsonl_files)
print(f" {display_name} ({len(jsonl_files)} 个会话)")
has_any = True

# Codex 项目
codex_sessions = find_codex_sessions()
if codex_sessions:
from collections import defaultdict
codex_by_dir: dict[str, list[Path]] = defaultdict(list)
for cf in codex_sessions:
try:
session = _parse_session(cf)
key = session.project_path or "Unknown"
except Exception:
key = "Unknown"
codex_by_dir[key].append(cf)

print("\n可用项目 (Codex):")
print("─" * 60)
for name, files in sorted(codex_by_dir.items()):
display = Path(name).name if "/" in name else name
print(f" {display} ({len(files)} 个会话)")
has_any = True

# Gemini 项目
gemini_sessions = find_gemini_sessions()
if gemini_sessions:
# 按项目目录分组
from collections import defaultdict
gemini_by_dir: dict[str, list[Path]] = defaultdict(list)
for gf in gemini_sessions:
try:
session = _parse_session(gf)
key = session.project_path or gf.parent.parent.name
except Exception:
key = gf.parent.parent.name
gemini_by_dir[key].append(gf)
projects = list_projects()
if not projects:
print("未找到项目数据")
print()
return

print("\n可用项目 (Gemini CLI):")
labels = {
SourceKind.CLAUDE: "Claude Code",
SourceKind.CODEX: "Codex",
SourceKind.GEMINI: "Gemini CLI",
}
by_source: dict[SourceKind, list] = {}
for project in projects:
by_source.setdefault(project.source, []).append(project)

for source in (SourceKind.CLAUDE, SourceKind.CODEX, SourceKind.GEMINI):
items = by_source.get(source, [])
if not items:
continue
print(f"\n可用项目 ({labels[source]}):")
print("─" * 60)
for name, files in sorted(gemini_by_dir.items()):
display = Path(name).name if "/" in name else name
print(f" {display} ({len(files)} 个会话)")
has_any = True

if not has_any:
print("未找到项目数据")
for project in items:
display_name = project.display_name
display = Path(display_name).name if "/" in display_name or "\\" in display_name else display_name
print(f" {display} ({project.session_count} 个会话)")
print()


Expand Down Expand Up @@ -394,9 +357,7 @@ def _show_rate_limit(args) -> None:
from .rate_limiter import analyze_rate_limit

# 收集所有会话文件(Claude + Codex + Gemini)
session_files: list[Path] = find_sessions()
session_files.extend(find_codex_sessions())
session_files.extend(find_gemini_sessions())
session_files: list[Path] = collect_session_files()

if not session_files:
print("未找到会话文件。", file=sys.stderr)
Expand Down Expand Up @@ -446,9 +407,7 @@ def _show_git_integration(args) -> None:
sys.exit(1)

# 收集所有会话文件
session_files: list[Path] = find_sessions()
session_files.extend(find_codex_sessions())
session_files.extend(find_gemini_sessions())
session_files: list[Path] = collect_session_files()

if not session_files:
import sys
Expand Down Expand Up @@ -702,24 +661,18 @@ def main(argv: list[str] | None = None) -> None:
if p.is_file() and p.suffix in (".jsonl", ".json"):
session_files = [p]
elif p.is_dir():
session_files = find_sessions(p)
session_files.extend(find_codex_sessions(p))
session_files = collect_session_files(project_dir=p)
if not session_files:
# 作为关键词模糊搜索(Claude + Codex + Gemini)
session_files = find_sessions_by_keyword(args.path)
session_files.extend(find_codex_sessions_by_keyword(args.path))
session_files.extend(find_gemini_sessions_by_keyword(args.path))
session_files = collect_session_files_by_keyword(args.path)
if not session_files:
print(f"找不到: {args.path}", file=sys.stderr)
sys.exit(1)
elif args.all:
session_files = find_sessions()
session_files.extend(find_codex_sessions())
session_files.extend(find_gemini_sessions())
session_files = collect_session_files()
else:
# 默认:当前目录
session_files = find_sessions(Path.cwd())
session_files.extend(find_codex_sessions(Path.cwd()))
session_files = collect_session_files(project_dir=Path.cwd())

# 去重(保留原顺序)
session_files = list(dict.fromkeys(session_files))
Expand Down
18 changes: 5 additions & 13 deletions cc_stats/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,8 @@
from datetime import datetime, timezone
from pathlib import Path

from .parser import (
Message,
Session,
find_codex_sessions,
find_gemini_sessions,
find_sessions,
parse_session_file,
)
from .parser import Message, Session
from .sources import collect_session_files, parse_file


def _extract_text(content) -> str:
Expand Down Expand Up @@ -121,9 +115,7 @@ def find_and_export(keyword: str, output: str | None = None,
include_tools: 是否包含工具调用
"""
# 搜索所有会话(Claude + Codex + Gemini)
all_files: list[Path] = list(find_sessions())
all_files.extend(find_codex_sessions())
all_files.extend(find_gemini_sessions())
all_files: list[Path] = collect_session_files()

# 先按 session ID 前缀匹配
matched = None
Expand All @@ -136,7 +128,7 @@ def find_and_export(keyword: str, output: str | None = None,
if not matched:
for f in sorted(all_files, key=lambda p: p.stat().st_mtime, reverse=True):
try:
session = parse_session_file(f)
session = parse_file(f)
for msg in session.messages:
text = _extract_text(msg.content)
if keyword.lower() in text.lower():
Expand All @@ -150,7 +142,7 @@ def find_and_export(keyword: str, output: str | None = None,
if not matched:
return None

session = parse_session_file(matched)
session = parse_file(matched)
md = export_session(session, include_tools=include_tools)

if output:
Expand Down
Loading