├── assets └── img1.png ├── src ├── core │ ├── mod.rs │ ├── segments │ │ ├── mod.rs │ │ ├── update.rs │ │ ├── directory.rs │ │ ├── model.rs │ │ ├── usage.rs │ │ ├── burn_rate.rs │ │ ├── cost.rs │ │ └── git.rs │ └── statusline.rs ├── lib.rs ├── utils │ ├── mod.rs │ ├── transcript.rs │ └── data_loader.rs ├── config │ ├── mod.rs │ ├── loader.rs │ ├── defaults.rs │ ├── types.rs │ └── block_overrides.rs ├── billing │ ├── mod.rs │ ├── types.rs │ ├── calculator.rs │ ├── pricing.rs │ └── block.rs ├── cli.rs ├── main.rs └── updater.rs ├── .gitignore ├── Cargo.toml ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── CHANGELOG.md ├── examples ├── test_full_statusline.rs └── test_litellm_fetch.rs ├── CLAUDE.md ├── README.zh.md └── README.md /assets/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/ccline/master/assets/img1.png -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod segments; 2 | pub mod statusline; 3 | 4 | pub use statusline::StatusLineGenerator; 5 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod billing; 2 | pub mod cli; 3 | pub mod config; 4 | pub mod core; 5 | pub mod updater; 6 | pub mod utils; 7 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod data_loader; 2 | pub mod transcript; 3 | 4 | pub use data_loader::DataLoader; 5 | pub use transcript::{extract_session_id, extract_usage_entry}; 6 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod block_overrides; 2 | pub mod defaults; 3 | pub mod loader; 4 | pub mod types; 5 | 6 | pub use block_overrides::*; 7 | pub use defaults::DEFAULT_CONFIG; 8 | pub use loader::ConfigLoader; 9 | pub use types::*; 10 | -------------------------------------------------------------------------------- /src/billing/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod block; 2 | pub mod calculator; 3 | pub mod pricing; 4 | pub mod types; 5 | 6 | pub use types::{ 7 | BillingBlock, BurnRate, BurnRateThresholds, BurnRateTrend, ModelPricing, SessionUsage, 8 | UsageEntry, 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Documentation and development files 4 | claude-powerline-features.md 5 | PRD.md 6 | .claude/ 7 | 8 | # Archive directory 9 | archive/ 10 | 11 | # Temporary test files 12 | test_pricing.rs 13 | test_pricing.sh 14 | 15 | # Development tools 16 | .vibedev/ 17 | -------------------------------------------------------------------------------- /src/config/loader.rs: -------------------------------------------------------------------------------- 1 | use super::types::Config; 2 | use std::path::Path; 3 | 4 | pub struct ConfigLoader; 5 | 6 | impl ConfigLoader { 7 | pub fn load() -> Config { 8 | // Return default config for now, implement multi-layer loading later 9 | Config::default() 10 | } 11 | 12 | pub fn load_from_path>(_path: P) -> Result> { 13 | // Load config from file 14 | Ok(Config::default()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/core/segments/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod burn_rate; 2 | pub mod cost; 3 | pub mod directory; 4 | pub mod git; 5 | pub mod model; 6 | pub mod update; 7 | pub mod usage; 8 | 9 | use crate::config::InputData; 10 | 11 | pub trait Segment { 12 | fn render(&self, input: &InputData) -> String; 13 | fn enabled(&self) -> bool; 14 | } 15 | 16 | // Re-export all segment types 17 | pub use burn_rate::BurnRateSegment; 18 | pub use cost::CostSegment; 19 | pub use directory::DirectorySegment; 20 | pub use git::GitSegment; 21 | pub use model::ModelSegment; 22 | pub use update::UpdateSegment; 23 | pub use usage::UsageSegment; 24 | -------------------------------------------------------------------------------- /src/core/segments/update.rs: -------------------------------------------------------------------------------- 1 | use crate::config::InputData; 2 | use crate::core::segments::Segment; 3 | use crate::updater::UpdateState; 4 | 5 | /// Update notification segment 6 | pub struct UpdateSegment { 7 | state: UpdateState, 8 | } 9 | 10 | impl UpdateSegment { 11 | pub fn new() -> Self { 12 | Self { 13 | state: UpdateState::load(), 14 | } 15 | } 16 | } 17 | 18 | impl Default for UpdateSegment { 19 | fn default() -> Self { 20 | Self::new() 21 | } 22 | } 23 | 24 | impl Segment for UpdateSegment { 25 | fn render(&self, _input: &InputData) -> String { 26 | self.state.status_text().unwrap_or_default() 27 | } 28 | 29 | fn enabled(&self) -> bool { 30 | self.state.status_text().is_some() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/config/defaults.rs: -------------------------------------------------------------------------------- 1 | use super::types::{Config, SegmentsConfig}; 2 | 3 | pub const DEFAULT_CONFIG: Config = Config { 4 | theme: String::new(), // Set to "dark" at runtime 5 | segments: SegmentsConfig { 6 | directory: true, 7 | git: true, 8 | model: true, 9 | usage: true, 10 | cost: true, 11 | burn_rate: true, 12 | }, 13 | }; 14 | 15 | impl Default for Config { 16 | fn default() -> Self { 17 | let cost_features_enabled = std::env::var("CCLINE_DISABLE_COST").is_err(); 18 | Config { 19 | theme: "dark".to_string(), 20 | segments: SegmentsConfig { 21 | directory: true, 22 | git: true, 23 | model: true, 24 | usage: true, 25 | cost: cost_features_enabled, 26 | burn_rate: cost_features_enabled, 27 | }, 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/core/segments/directory.rs: -------------------------------------------------------------------------------- 1 | use super::Segment; 2 | use crate::config::InputData; 3 | use std::path::Path; 4 | 5 | pub struct DirectorySegment { 6 | enabled: bool, 7 | } 8 | 9 | impl DirectorySegment { 10 | pub fn new(enabled: bool) -> Self { 11 | Self { enabled } 12 | } 13 | } 14 | 15 | impl Segment for DirectorySegment { 16 | fn render(&self, input: &InputData) -> String { 17 | if !self.enabled { 18 | return String::new(); 19 | } 20 | 21 | let dir_name = get_current_dir_name(&input.workspace.current_dir); 22 | format!("\u{f024b} {}", dir_name) 23 | } 24 | 25 | fn enabled(&self) -> bool { 26 | self.enabled 27 | } 28 | } 29 | 30 | fn get_current_dir_name>(path: P) -> String { 31 | path.as_ref() 32 | .file_name() 33 | .and_then(|name| name.to_str()) 34 | .unwrap_or("unknown") 35 | .to_string() 36 | } 37 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ccometixline" 3 | version = "0.1.2" 4 | edition = "2021" 5 | description = "CCometixLine (ccline) - High-performance Claude Code StatusLine tool written in Rust" 6 | authors = ["Haleclipse"] 7 | license = "MIT" 8 | repository = "https://github.com/username/CCometixLine" 9 | readme = "README.md" 10 | keywords = ["claude", "statusline", "powerline", "rust", "claude-code"] 11 | categories = ["command-line-utilities", "development-tools"] 12 | 13 | [dependencies] 14 | serde = { version = "1.0", features = ["derive"] } 15 | serde_json = "1.0" 16 | clap = { version = "4.0", features = ["derive"] } 17 | toml = "0.8" 18 | ureq = { version = "2.10", features = ["json"], optional = true } 19 | semver = { version = "1.0", optional = true } 20 | chrono = { version = "0.4", features = ["serde"] } 21 | dirs = { version = "5.0", optional = true } 22 | tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros"] } 23 | reqwest = { version = "0.12", features = ["json"] } 24 | once_cell = "1.20" 25 | glob = "0.3" 26 | 27 | [features] 28 | default = ["self-update"] 29 | self-update = ["ureq", "semver", "dirs"] 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | pull_request: 7 | branches: [ master, main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test Suite 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install Rust 21 | uses: dtolnay/rust-toolchain@stable 22 | 23 | - name: Cache cargo registry 24 | uses: actions/cache@v4 25 | with: 26 | path: | 27 | ~/.cargo/registry 28 | ~/.cargo/git 29 | target 30 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 31 | 32 | - name: Run tests 33 | run: cargo test --verbose 34 | 35 | - name: Check formatting 36 | run: cargo fmt -- --check 37 | 38 | - name: Run clippy 39 | run: cargo clippy -- -D warnings 40 | 41 | build: 42 | name: Build Check 43 | runs-on: ${{ matrix.os }} 44 | strategy: 45 | matrix: 46 | os: [ubuntu-latest, windows-latest, macos-latest] 47 | steps: 48 | - name: Checkout 49 | uses: actions/checkout@v4 50 | 51 | - name: Install Rust 52 | uses: dtolnay/rust-toolchain@stable 53 | 54 | - name: Build 55 | run: cargo build --release -------------------------------------------------------------------------------- /src/core/segments/model.rs: -------------------------------------------------------------------------------- 1 | use super::Segment; 2 | use crate::config::InputData; 3 | 4 | pub struct ModelSegment { 5 | enabled: bool, 6 | } 7 | 8 | impl ModelSegment { 9 | pub fn new(enabled: bool) -> Self { 10 | Self { enabled } 11 | } 12 | } 13 | 14 | impl Segment for ModelSegment { 15 | fn render(&self, input: &InputData) -> String { 16 | if !self.enabled { 17 | return String::new(); 18 | } 19 | 20 | format!( 21 | "\u{e26d} {}", 22 | self.format_model_name(&input.model.display_name) 23 | ) 24 | } 25 | 26 | fn enabled(&self) -> bool { 27 | self.enabled 28 | } 29 | } 30 | 31 | impl ModelSegment { 32 | fn format_model_name(&self, display_name: &str) -> String { 33 | // Simplify model display names 34 | match display_name { 35 | name if name.contains("claude-3-5-sonnet") => "Sonnet 3.5".to_string(), 36 | name if name.contains("claude-3-7-sonnet") => "Sonnet 3.7".to_string(), 37 | name if name.contains("claude-3-sonnet") => "Sonnet 3".to_string(), 38 | name if name.contains("claude-3-haiku") => "Haiku 3".to_string(), 39 | name if name.contains("claude-4-sonnet") => "Sonnet 4".to_string(), 40 | name if name.contains("claude-4-opus") => "Opus 4".to_string(), 41 | name if name.contains("sonnet-4") => "Sonnet 4".to_string(), 42 | _ => display_name.to_string(), 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | #[derive(Parser, Debug)] 4 | #[command(name = "CCometixLine (ccline)")] 5 | #[command(disable_version_flag = true)] 6 | #[command( 7 | about = "CCometixLine (ccline) - High-performance Claude Code StatusLine tool written in Rust" 8 | )] 9 | #[command( 10 | long_about = concat!( 11 | "CCometixLine (ccline) v", env!("CARGO_PKG_VERSION"), "\n", 12 | "A high-performance Claude Code StatusLine tool written in Rust.\n", 13 | "Provides real-time usage tracking, Git integration, and customizable themes." 14 | ) 15 | )] 16 | pub struct Cli { 17 | /// Configuration file path 18 | #[arg(short, long)] 19 | pub config: Option, 20 | 21 | /// Theme selection 22 | #[arg(short, long, default_value = "dark")] 23 | pub theme: String, 24 | 25 | /// Enable TUI configuration mode 26 | #[arg(long)] 27 | pub configure: bool, 28 | 29 | /// Print default configuration 30 | #[arg(long)] 31 | pub print_config: bool, 32 | 33 | /// Validate configuration file 34 | #[arg(long)] 35 | pub validate: bool, 36 | 37 | /// Update to the latest version 38 | #[arg(long)] 39 | pub update: bool, 40 | 41 | /// Show current version 42 | #[arg(short = 'v', long = "version")] 43 | pub version: bool, 44 | 45 | /// Set block start time for today (formats: 0-23, HH:MM, ISO timestamp) 46 | #[arg(long, value_name = "TIME")] 47 | pub set_block_start: Option, 48 | 49 | /// Clear block start override for today 50 | #[arg(long)] 51 | pub clear_block_start: bool, 52 | 53 | /// Show current block override status 54 | #[arg(long)] 55 | pub show_block_status: bool, 56 | } 57 | 58 | impl Cli { 59 | pub fn parse_args() -> Self { 60 | Self::parse() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.1] - 2025-08-12 9 | 10 | ### Added 11 | - Support for `total_tokens` field in token calculation for better accuracy with GLM-4.5 and similar providers 12 | - Proper Git repository detection using `git rev-parse --git-dir` 13 | - Cross-platform compatibility improvements for Windows path handling 14 | - Pre-commit hooks for automatic code formatting 15 | - **Static Linux binary**: Added musl-based static binary for universal Linux compatibility without glibc dependencies 16 | 17 | ### Changed 18 | - **Token calculation priority**: `total_tokens` → Claude format → OpenAI format → fallback 19 | - **Display formatting**: Removed redundant ".0" from integer percentages and token counts 20 | - `0.0%` → `0%`, `25.0%` → `25%`, `50.0k` → `50k` 21 | - **CI/CD**: Updated GitHub Actions to use Ubuntu 22.04 for Linux builds and ubuntu-latest for Windows cross-compilation 22 | - **Binary distribution**: Now provides two Linux options - dynamic (glibc) and static (musl) binaries 23 | - **Version management**: Unified version number using `env!("CARGO_PKG_VERSION")` 24 | 25 | ### Fixed 26 | - Git segment now properly hides for non-Git directories instead of showing misleading "detached" status 27 | - Windows Git repository path handling issues by removing overly aggressive path sanitization 28 | - GitHub Actions runner compatibility issues (updated to supported versions: ubuntu-22.04 for Linux, ubuntu-latest for Windows) 29 | - **Git version compatibility**: Added fallback to `git symbolic-ref` for Git versions < 2.22 when `--show-current` is not available 30 | 31 | ### Removed 32 | - Path sanitization function that could break Windows paths in Git operations 33 | 34 | ## [0.1.0] - 2025-08-11 35 | 36 | ### Added 37 | - Initial release of CCometixLine 38 | - High-performance Rust-based statusline tool for Claude Code 39 | - Git integration with branch, status, and tracking info 40 | - Model display with simplified Claude model names 41 | - Usage tracking based on transcript analysis 42 | - Directory display showing current workspace 43 | - Minimal design using Nerd Font icons 44 | - Cross-platform support (Linux, macOS, Windows) 45 | - Command-line configuration options 46 | - GitHub Actions CI/CD pipeline 47 | 48 | ### Technical Details 49 | - Context limit: 200,000 tokens 50 | - Startup time: < 50ms 51 | - Memory usage: < 10MB 52 | - Binary size: ~2MB optimized release build 53 | 54 | -------------------------------------------------------------------------------- /src/core/statusline.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{Config, InputData}; 2 | use crate::core::segments::{ 3 | BurnRateSegment, CostSegment, DirectorySegment, GitSegment, ModelSegment, Segment, 4 | UpdateSegment, UsageSegment, 5 | }; 6 | 7 | pub struct StatusLineGenerator { 8 | config: Config, 9 | } 10 | 11 | impl StatusLineGenerator { 12 | pub fn new(config: Config) -> Self { 13 | Self { config } 14 | } 15 | 16 | pub fn generate(&self, input: &InputData) -> String { 17 | let mut segments = Vec::new(); 18 | 19 | // Assemble segments with proper colors 20 | if self.config.segments.model { 21 | let model_segment = ModelSegment::new(true); 22 | let content = model_segment.render(input); 23 | segments.push(format!("\x1b[1;36m{}\x1b[0m", content)); 24 | } 25 | 26 | if self.config.segments.directory { 27 | let dir_segment = DirectorySegment::new(true); 28 | let content = dir_segment.render(input); 29 | // Extract directory name without icon 30 | let dir_name = content.trim_start_matches('\u{f024b}').trim_start(); 31 | segments.push(format!( 32 | "\x1b[1;33m\u{f024b}\x1b[0m \x1b[1;32m{}\x1b[0m", 33 | dir_name 34 | )); 35 | } 36 | 37 | if self.config.segments.git { 38 | let git_segment = GitSegment::new(true); 39 | let git_output = git_segment.render(input); 40 | if !git_output.is_empty() { 41 | segments.push(format!("\x1b[1;34m{}\x1b[0m", git_output)); 42 | } 43 | } 44 | 45 | if self.config.segments.usage { 46 | let usage_segment = UsageSegment::new(true); 47 | let content = usage_segment.render(input); 48 | segments.push(format!("\x1b[1;35m{}\x1b[0m", content)); 49 | } 50 | 51 | // Add cost segment 52 | if self.config.segments.cost { 53 | let cost_segment = CostSegment::new(true); 54 | let content = cost_segment.render(input); 55 | segments.push(format!("\x1b[1;33m{}\x1b[0m", content)); // Yellow 56 | } 57 | 58 | // Add burn rate segment 59 | if self.config.segments.burn_rate { 60 | let burn_rate_segment = BurnRateSegment::new(true); 61 | let content = burn_rate_segment.render(input); 62 | segments.push(format!("\x1b[1;31m{}\x1b[0m", content)); // Red 63 | } 64 | 65 | // Add update segment (always enabled when there's an update) 66 | let update_segment = UpdateSegment::new(); 67 | if update_segment.enabled() { 68 | let content = update_segment.render(input); 69 | segments.push(format!("\x1b[1;37m{}\x1b[0m", content)); 70 | } 71 | 72 | // Join segments with white separator 73 | segments.join("\x1b[37m | \x1b[0m") 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/core/segments/usage.rs: -------------------------------------------------------------------------------- 1 | use super::Segment; 2 | use crate::config::{InputData, TranscriptEntry}; 3 | use std::fs; 4 | use std::io::{BufRead, BufReader}; 5 | use std::path::Path; 6 | 7 | const CONTEXT_LIMIT: u32 = 200000; 8 | 9 | pub struct UsageSegment { 10 | enabled: bool, 11 | } 12 | 13 | impl UsageSegment { 14 | pub fn new(enabled: bool) -> Self { 15 | Self { enabled } 16 | } 17 | } 18 | 19 | impl Segment for UsageSegment { 20 | fn render(&self, input: &InputData) -> String { 21 | if !self.enabled { 22 | return String::new(); 23 | } 24 | 25 | let context_used_token = parse_transcript_usage(&input.transcript_path); 26 | let context_used_rate = (context_used_token as f64 / CONTEXT_LIMIT as f64) * 100.0; 27 | 28 | // Format percentage: show integer when whole number, decimal when fractional 29 | let percentage_display = if context_used_rate.fract() == 0.0 { 30 | format!("{:.0}%", context_used_rate) 31 | } else { 32 | format!("{:.1}%", context_used_rate) 33 | }; 34 | 35 | // Format tokens: show integer k when whole number, decimal k when fractional 36 | let tokens_display = if context_used_token >= 1000 { 37 | let k_value = context_used_token as f64 / 1000.0; 38 | if k_value.fract() == 0.0 { 39 | format!("{}k", k_value as u32) 40 | } else { 41 | format!("{:.1}k", k_value) 42 | } 43 | } else { 44 | context_used_token.to_string() 45 | }; 46 | 47 | format!( 48 | "\u{f49b} {} · {} tokens", 49 | percentage_display, tokens_display 50 | ) 51 | } 52 | 53 | fn enabled(&self) -> bool { 54 | self.enabled 55 | } 56 | } 57 | 58 | fn parse_transcript_usage>(transcript_path: P) -> u32 { 59 | let file = match fs::File::open(&transcript_path) { 60 | Ok(file) => file, 61 | Err(_) => return 0, 62 | }; 63 | 64 | let reader = BufReader::new(file); 65 | let lines: Vec = reader 66 | .lines() 67 | .collect::, _>>() 68 | .unwrap_or_default(); 69 | 70 | for line in lines.iter().rev() { 71 | let line = line.trim(); 72 | if line.is_empty() { 73 | continue; 74 | } 75 | 76 | if let Ok(entry) = serde_json::from_str::(line) { 77 | if entry.r#type.as_deref() == Some("assistant") { 78 | if let Some(message) = &entry.message { 79 | if let Some(raw_usage) = &message.usage { 80 | let normalized = raw_usage.clone().normalize(); 81 | // Use display_tokens() which prioritizes context-relevant tokens 82 | return normalized.display_tokens(); 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | 0 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | build: 14 | name: Build for ${{ matrix.target }} 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | include: 19 | - target: x86_64-unknown-linux-gnu 20 | os: ubuntu-22.04 21 | name: ccline-linux-x64.tar.gz 22 | - target: x86_64-unknown-linux-musl 23 | os: ubuntu-latest 24 | name: ccline-linux-x64-static.tar.gz 25 | - target: x86_64-pc-windows-gnu 26 | os: ubuntu-latest 27 | name: ccline-windows-x64.zip 28 | - target: x86_64-apple-darwin 29 | os: macos-latest 30 | name: ccline-macos-x64.tar.gz 31 | - target: aarch64-apple-darwin 32 | os: macos-latest 33 | name: ccline-macos-arm64.tar.gz 34 | 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v4 38 | 39 | - name: Install Rust 40 | uses: dtolnay/rust-toolchain@stable 41 | with: 42 | targets: ${{ matrix.target }} 43 | 44 | - name: Install cross-compilation tools 45 | if: matrix.target == 'x86_64-pc-windows-gnu' 46 | run: | 47 | sudo apt-get update 48 | sudo apt-get install -y mingw-w64 49 | 50 | - name: Install musl tools 51 | if: matrix.target == 'x86_64-unknown-linux-musl' 52 | run: | 53 | sudo apt-get update 54 | sudo apt-get install -y musl-tools 55 | 56 | - name: Build binary 57 | run: cargo build --release --target ${{ matrix.target }} 58 | 59 | - name: Package Linux/macOS 60 | if: matrix.os != 'windows-latest' && matrix.target != 'x86_64-pc-windows-gnu' 61 | run: | 62 | mkdir -p dist 63 | cp target/${{ matrix.target }}/release/ccometixline dist/ccline 64 | cd dist 65 | tar czf ../${{ matrix.name }} ccline 66 | 67 | - name: Package Windows 68 | if: matrix.target == 'x86_64-pc-windows-gnu' 69 | run: | 70 | mkdir -p dist 71 | cp target/${{ matrix.target }}/release/ccometixline.exe dist/ccline.exe 72 | cd dist 73 | zip ../${{ matrix.name }} ccline.exe 74 | 75 | - name: Upload artifact 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: ${{ matrix.name }} 79 | path: ${{ matrix.name }} 80 | 81 | release: 82 | name: Create Release 83 | runs-on: ubuntu-latest 84 | needs: build 85 | if: startsWith(github.ref, 'refs/tags/') 86 | steps: 87 | - name: Checkout 88 | uses: actions/checkout@v4 89 | 90 | - name: Download artifacts 91 | uses: actions/download-artifact@v4 92 | with: 93 | path: artifacts 94 | 95 | - name: Create Release 96 | uses: softprops/action-gh-release@v2 97 | with: 98 | files: artifacts/*/* 99 | generate_release_notes: true 100 | draft: false 101 | prerelease: false -------------------------------------------------------------------------------- /examples/test_full_statusline.rs: -------------------------------------------------------------------------------- 1 | use ccometixline::config::{Config, InputData, Model, Workspace}; 2 | use ccometixline::core::StatusLineGenerator; 3 | 4 | fn main() { 5 | println!("Testing Full Statusline with Cost Tracking"); 6 | println!("===========================================\n"); 7 | 8 | // Create test configuration with all segments enabled 9 | let config = Config { 10 | segments: ccometixline::config::SegmentsConfig { 11 | model: true, 12 | directory: true, 13 | git: true, 14 | usage: true, 15 | cost: true, 16 | burn_rate: true, 17 | }, 18 | theme: "nerdfonts".to_string(), 19 | }; 20 | 21 | // Create test input data 22 | let input = InputData { 23 | model: Model { 24 | display_name: "claude-3-5-sonnet-20241022".to_string(), 25 | }, 26 | workspace: Workspace { 27 | current_dir: "/home/user/projects/test-project".to_string(), 28 | }, 29 | transcript_path: "/home/user/.claude/projects/test/session-123.jsonl".to_string(), 30 | }; 31 | 32 | // Generate statusline 33 | let generator = StatusLineGenerator::new(config.clone()); 34 | let statusline = generator.generate(&input); 35 | 36 | println!("Generated Statusline:"); 37 | println!("{}", statusline); 38 | println!(); 39 | 40 | // Test with different configurations 41 | println!("Testing Segment Order and Configuration:"); 42 | println!("-----------------------------------------"); 43 | 44 | // Test with only model and usage 45 | let minimal_config = Config { 46 | segments: ccometixline::config::SegmentsConfig { 47 | model: true, 48 | directory: false, 49 | git: false, 50 | usage: true, 51 | cost: false, 52 | burn_rate: false, 53 | }, 54 | theme: "nerdfonts".to_string(), 55 | }; 56 | 57 | let minimal_generator = StatusLineGenerator::new(minimal_config); 58 | let minimal_statusline = minimal_generator.generate(&input); 59 | println!("Minimal (Model + Usage): {}", minimal_statusline); 60 | 61 | // Test with cost tracking only 62 | let cost_config = Config { 63 | segments: ccometixline::config::SegmentsConfig { 64 | model: true, 65 | directory: true, 66 | git: false, 67 | usage: false, 68 | cost: true, 69 | burn_rate: false, 70 | }, 71 | theme: "nerdfonts".to_string(), 72 | }; 73 | 74 | let cost_generator = StatusLineGenerator::new(cost_config); 75 | let cost_statusline = cost_generator.generate(&input); 76 | println!("Cost Tracking: {}", cost_statusline); 77 | 78 | // Test with burn rate only 79 | let burn_config = Config { 80 | segments: ccometixline::config::SegmentsConfig { 81 | model: true, 82 | directory: true, 83 | git: false, 84 | usage: false, 85 | cost: false, 86 | burn_rate: true, 87 | }, 88 | theme: "nerdfonts".to_string(), 89 | }; 90 | 91 | let burn_generator = StatusLineGenerator::new(burn_config); 92 | let burn_statusline = burn_generator.generate(&input); 93 | println!("Burn Rate: {}", burn_statusline); 94 | 95 | // Test segment ordering 96 | println!("\n✓ Segment Order Verification:"); 97 | println!(" 1. Model"); 98 | println!(" 2. Directory"); 99 | println!(" 3. Git"); 100 | println!(" 4. Usage"); 101 | println!(" 5. Cost (NEW)"); 102 | println!(" 6. BurnRate (NEW)"); 103 | println!(" 7. Update (if available)"); 104 | 105 | println!("\n✅ Integration test completed successfully!"); 106 | } 107 | -------------------------------------------------------------------------------- /src/utils/transcript.rs: -------------------------------------------------------------------------------- 1 | use crate::billing::UsageEntry; 2 | use crate::config::{NormalizedUsage, TranscriptEntry}; 3 | use chrono::{DateTime, Utc}; 4 | use std::collections::HashSet; 5 | 6 | /// Extract session ID from file path (the UUID part) 7 | pub fn extract_session_id(path: &std::path::Path) -> String { 8 | path.file_stem() 9 | .and_then(|s| s.to_str()) 10 | .unwrap_or("unknown") 11 | .to_string() 12 | } 13 | 14 | /// Parse a JSONL line and extract usage entry if valid 15 | pub fn parse_line_to_usage( 16 | line: &str, 17 | session_id: &str, 18 | seen: &mut HashSet, 19 | ) -> Option { 20 | // Parse the JSON line 21 | let entry: TranscriptEntry = serde_json::from_str(line).ok()?; 22 | 23 | // Only process assistant messages with usage data 24 | if entry.r#type.as_deref() != Some("assistant") { 25 | return None; 26 | } 27 | 28 | let message = entry.message.as_ref()?; 29 | let raw_usage = message.usage.as_ref()?; 30 | 31 | // Deduplication check - match ccusage behavior exactly 32 | if let (Some(msg_id), Some(req_id)) = (message.id.as_ref(), entry.request_id.as_ref()) { 33 | // Use message_id:request_id when both are available 34 | let hash = format!("{}:{}", msg_id, req_id); 35 | if seen.contains(&hash) { 36 | return None; // Skip duplicate 37 | } 38 | seen.insert(hash); 39 | } 40 | // For null ID entries: don't deduplicate (matching ccusage behavior) 41 | 42 | // Normalize the usage data 43 | let normalized = raw_usage.clone().normalize(); 44 | 45 | // Get model name from message 46 | let model = message.model.as_deref(); 47 | 48 | // Convert to UsageEntry 49 | extract_usage_entry(&normalized, session_id, entry.timestamp.as_deref(), model) 50 | } 51 | 52 | /// Convert NormalizedUsage to UsageEntry 53 | pub fn extract_usage_entry( 54 | normalized: &NormalizedUsage, 55 | session_id: &str, 56 | timestamp_str: Option<&str>, 57 | model: Option<&str>, 58 | ) -> Option { 59 | // Parse timestamp or use current time 60 | let timestamp = if let Some(ts_str) = timestamp_str { 61 | DateTime::parse_from_rfc3339(ts_str) 62 | .ok() 63 | .map(|dt| dt.with_timezone(&Utc)) 64 | .unwrap_or_else(Utc::now) 65 | } else { 66 | Utc::now() 67 | }; 68 | 69 | Some(UsageEntry { 70 | timestamp, 71 | input_tokens: normalized.input_tokens, 72 | output_tokens: normalized.output_tokens, 73 | cache_creation_tokens: normalized.cache_creation_input_tokens, 74 | cache_read_tokens: normalized.cache_read_input_tokens, 75 | model: model.unwrap_or("").to_string(), 76 | cost: None, // Will be calculated later with pricing data 77 | session_id: session_id.to_string(), 78 | }) 79 | } 80 | 81 | #[cfg(test)] 82 | mod tests { 83 | use super::*; 84 | 85 | #[test] 86 | fn test_extract_session_id() { 87 | let path = std::path::Path::new( 88 | "/home/user/.claude/projects/test/c040b0ba-658d-4188-befa-0d2dad1f0ea5.jsonl", 89 | ); 90 | assert_eq!( 91 | extract_session_id(path), 92 | "c040b0ba-658d-4188-befa-0d2dad1f0ea5" 93 | ); 94 | } 95 | 96 | #[test] 97 | fn test_normalized_to_usage_entry() { 98 | let normalized = NormalizedUsage { 99 | input_tokens: 100, 100 | output_tokens: 50, 101 | total_tokens: 150, 102 | cache_creation_input_tokens: 10, 103 | cache_read_input_tokens: 5, 104 | calculation_source: "test".to_string(), 105 | raw_data_available: vec![], 106 | }; 107 | 108 | let entry = 109 | extract_usage_entry(&normalized, "test-session", None, Some("claude-3-5-sonnet")) 110 | .unwrap(); 111 | assert_eq!(entry.input_tokens, 100); 112 | assert_eq!(entry.output_tokens, 50); 113 | assert_eq!(entry.cache_creation_tokens, 10); 114 | assert_eq!(entry.cache_read_tokens, 5); 115 | assert_eq!(entry.session_id, "test-session"); 116 | assert_eq!(entry.model, "claude-3-5-sonnet"); 117 | assert!(entry.cost.is_none()); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/core/segments/burn_rate.rs: -------------------------------------------------------------------------------- 1 | use super::Segment; 2 | use crate::billing::{ 3 | block::{find_active_block, identify_session_blocks_with_overrides}, 4 | calculator::calculate_burn_rate, 5 | BurnRateThresholds, ModelPricing, 6 | }; 7 | use crate::config::InputData; 8 | use crate::utils::data_loader::DataLoader; 9 | 10 | pub struct BurnRateSegment { 11 | enabled: bool, 12 | thresholds: BurnRateThresholds, 13 | } 14 | 15 | impl BurnRateSegment { 16 | pub fn new(enabled: bool) -> Self { 17 | Self { 18 | enabled, 19 | thresholds: BurnRateThresholds::from_env(), 20 | } 21 | } 22 | 23 | fn get_indicator(&self, tokens_per_minute: f64) -> &'static str { 24 | if tokens_per_minute > self.thresholds.high { 25 | "\u{ef76}" // 🔥 Fire (Nerd Font) 26 | } else if tokens_per_minute > self.thresholds.medium { 27 | "\u{f0e7}" // ⚡ Lightning bolt (Nerd Font) 28 | } else { 29 | "\u{f0e4}" // 📊 Dashboard/gauge (Nerd Font) 30 | } 31 | } 32 | 33 | fn render_with_data(&self, _input: &InputData) -> String { 34 | // Load all project data globally (like ccusage does) 35 | let data_loader = DataLoader::new(); 36 | let mut all_entries = data_loader.load_all_projects(); 37 | 38 | // Get pricing data (create a runtime to handle async) 39 | let pricing_map = { 40 | let rt = tokio::runtime::Runtime::new().unwrap(); 41 | rt.block_on(async { ModelPricing::get_pricing_with_fallback().await }) 42 | }; 43 | 44 | // Calculate costs for entries 45 | for entry in &mut all_entries { 46 | if let Some(pricing) = ModelPricing::get_model_pricing(&pricing_map, &entry.model) { 47 | entry.cost = Some(pricing.calculate_cost(entry)); 48 | } 49 | } 50 | 51 | // Find active billing block using dynamic calculation 52 | let blocks = identify_session_blocks_with_overrides(&all_entries); 53 | let active_block = find_active_block(&blocks); 54 | 55 | // Calculate burn rate 56 | match active_block.and_then(|block| calculate_burn_rate(block, &all_entries)) { 57 | Some(rate) => { 58 | let indicator = self.get_indicator(rate.tokens_per_minute_for_indicator); 59 | format!("{} ${:.2}/hr", indicator, rate.cost_per_hour) 60 | } 61 | None => "\u{f0e4} —/hr".to_string(), // No data available 62 | } 63 | } 64 | } 65 | 66 | impl Segment for BurnRateSegment { 67 | fn render(&self, input: &InputData) -> String { 68 | if !self.enabled { 69 | return String::new(); 70 | } 71 | 72 | // Handle potential errors gracefully 73 | match std::panic::catch_unwind(|| self.render_with_data(input)) { 74 | Ok(result) => result, 75 | Err(_) => "\u{f0e4} —/hr".to_string(), // Error fallback 76 | } 77 | } 78 | 79 | fn enabled(&self) -> bool { 80 | self.enabled 81 | } 82 | } 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use super::*; 87 | use crate::config::{Model, Workspace}; 88 | 89 | #[test] 90 | fn test_burn_rate_segment_disabled() { 91 | let segment = BurnRateSegment::new(false); 92 | let input = InputData { 93 | model: Model { 94 | display_name: "test-model".to_string(), 95 | }, 96 | workspace: Workspace { 97 | current_dir: "/test".to_string(), 98 | }, 99 | transcript_path: "/test/transcript.jsonl".to_string(), 100 | }; 101 | 102 | assert_eq!(segment.render(&input), ""); 103 | assert!(!segment.enabled()); 104 | } 105 | 106 | #[test] 107 | fn test_burn_rate_segment_enabled() { 108 | let segment = BurnRateSegment::new(true); 109 | assert!(segment.enabled()); 110 | } 111 | 112 | #[test] 113 | fn test_indicator_selection() { 114 | let segment = BurnRateSegment::new(true); 115 | 116 | // Test high burn rate 117 | assert_eq!(segment.get_indicator(6000.0), "\u{ef76}"); // Fire 118 | 119 | // Test medium burn rate 120 | assert_eq!(segment.get_indicator(3000.0), "\u{f0e7}"); // Lightning 121 | 122 | // Test normal burn rate 123 | assert_eq!(segment.get_indicator(1000.0), "\u{f0e4}"); // Dashboard 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/utils/data_loader.rs: -------------------------------------------------------------------------------- 1 | use crate::billing::UsageEntry; 2 | use glob::glob; 3 | use std::collections::HashSet; 4 | use std::fs; 5 | use std::io::{Read, Seek, SeekFrom}; 6 | use std::path::{Path, PathBuf}; 7 | 8 | pub struct DataLoader { 9 | project_dirs: Vec, 10 | } 11 | 12 | impl DataLoader { 13 | pub fn new() -> Self { 14 | Self { 15 | project_dirs: Self::find_claude_dirs(), 16 | } 17 | } 18 | 19 | /// Find all Claude data directories 20 | fn find_claude_dirs() -> Vec { 21 | let mut dirs = Vec::new(); 22 | 23 | // Get home directory 24 | if let Ok(home) = std::env::var("HOME") { 25 | // New version path (~/.config/claude/projects) 26 | let new_path = PathBuf::from(&home).join(".config/claude/projects"); 27 | if new_path.exists() { 28 | dirs.push(new_path); 29 | } 30 | 31 | // Legacy path (~/.claude/projects) 32 | let old_path = PathBuf::from(&home).join(".claude/projects"); 33 | if old_path.exists() { 34 | dirs.push(old_path); 35 | } 36 | } 37 | 38 | // Support custom directories via environment variable 39 | if let Ok(custom_dirs) = std::env::var("CLAUDE_CONFIG_DIR") { 40 | for dir in custom_dirs.split(',') { 41 | let path = PathBuf::from(dir.trim()).join("projects"); 42 | if path.exists() { 43 | dirs.push(path); 44 | } 45 | } 46 | } 47 | 48 | dirs 49 | } 50 | 51 | /// Load all usage data from all projects (fresh read every time) 52 | pub fn load_all_projects(&self) -> Vec { 53 | let mut all_entries = Vec::new(); 54 | let mut seen_hashes = HashSet::new(); 55 | 56 | // Scan all project directories 57 | for dir in &self.project_dirs { 58 | let pattern = format!("{}/**/*.jsonl", dir.display()); 59 | if let Ok(paths) = glob(&pattern) { 60 | for path in paths.flatten() { 61 | // Parse individual file 62 | let entries = self.parse_jsonl_file(&path, &mut seen_hashes); 63 | all_entries.extend(entries); 64 | } 65 | } 66 | } 67 | 68 | // Sort by timestamp 69 | all_entries.sort_by_key(|e| e.timestamp); 70 | 71 | all_entries 72 | } 73 | 74 | /// Parse a single JSONL file 75 | fn parse_jsonl_file(&self, path: &Path, seen: &mut HashSet) -> Vec { 76 | let mut entries = Vec::new(); 77 | 78 | // Extract session_id from filename (UUID) 79 | let session_id = path 80 | .file_stem() 81 | .and_then(|s| s.to_str()) 82 | .unwrap_or("unknown") 83 | .to_string(); 84 | 85 | // Read file content (handle large files) 86 | let content = match fs::metadata(path) { 87 | Ok(metadata) if metadata.len() > 100 * 1024 * 1024 => { 88 | // File > 100MB, only read last 10MB 89 | self.read_last_n_bytes(path, 10 * 1024 * 1024) 90 | } 91 | _ => fs::read_to_string(path).unwrap_or_default(), 92 | }; 93 | 94 | // Parse each line 95 | for line in content.lines() { 96 | if line.trim().is_empty() { 97 | continue; 98 | } 99 | 100 | // Parse transcript entry and extract usage 101 | if let Some(usage_entry) = 102 | crate::utils::transcript::parse_line_to_usage(line, &session_id, seen) 103 | { 104 | entries.push(usage_entry); 105 | } 106 | } 107 | 108 | entries 109 | } 110 | 111 | /// Read the last N bytes of a file 112 | fn read_last_n_bytes(&self, path: &Path, n: usize) -> String { 113 | let mut file = match fs::File::open(path) { 114 | Ok(f) => f, 115 | Err(_) => return String::new(), 116 | }; 117 | 118 | let file_len = match file.metadata() { 119 | Ok(m) => m.len(), 120 | Err(_) => return String::new(), 121 | }; 122 | 123 | let start_pos = file_len.saturating_sub(n as u64); 124 | 125 | // Seek to start position 126 | if file.seek(SeekFrom::Start(start_pos)).is_err() { 127 | return String::new(); 128 | } 129 | 130 | let mut buffer = Vec::new(); 131 | let _ = file.read_to_end(&mut buffer); 132 | 133 | // Find first complete line (skip partial line at beginning) 134 | if let Some(pos) = buffer.iter().position(|&b| b == b'\n') { 135 | buffer.drain(..=pos); 136 | } 137 | 138 | String::from_utf8_lossy(&buffer).to_string() 139 | } 140 | } 141 | 142 | impl Default for DataLoader { 143 | fn default() -> Self { 144 | Self::new() 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | CCometixLine is a high-performance Claude Code statusline tool written in Rust that provides real-time usage tracking, Git integration, cost monitoring, and burn rate analysis. The tool integrates with Claude Code's statusline system to display comprehensive development information. 8 | 9 | ## Development Commands 10 | 11 | ### Building and Testing 12 | ```bash 13 | # Build development version 14 | cargo build 15 | 16 | # Build optimized release version 17 | cargo build --release 18 | 19 | # Run tests 20 | cargo test 21 | 22 | # Check code formatting 23 | cargo fmt --check 24 | 25 | # Run linting 26 | cargo clippy 27 | 28 | # Run all checks (format, clippy, test) 29 | cargo fmt --check && cargo clippy && cargo test 30 | ``` 31 | 32 | ### Running the Application 33 | ```bash 34 | # Run directly from source 35 | cargo run 36 | 37 | # Run with specific CLI arguments 38 | cargo run -- --help 39 | cargo run -- --print-config 40 | cargo run -- --show-block-status 41 | 42 | # Test statusline generation (requires JSON input) 43 | echo '{"model":"claude-3-5-sonnet","workingDirectory":"/test","gitBranch":"main"}' | cargo run 44 | ``` 45 | 46 | ### Installation and Distribution 47 | ```bash 48 | # Install locally 49 | cargo install --path . 50 | 51 | # Create release binaries 52 | cargo build --release 53 | cp target/release/ccometixline ~/.claude/ccline/ccline 54 | ``` 55 | 56 | ## Architecture Overview 57 | 58 | ### Core Components 59 | 60 | 1. **CLI Module** (`src/cli.rs`): Command-line argument parsing using clap 61 | - Handles version, config, update, and block management commands 62 | - Provides user-facing interface for all tool operations 63 | 64 | 2. **Configuration System** (`src/config/`): 65 | - `types.rs`: Core configuration structures and data models 66 | - `loader.rs`: Configuration loading logic with environment variable support 67 | - `defaults.rs`: Default configuration values 68 | - `block_overrides.rs`: Manual billing block synchronization management 69 | 70 | 3. **Core Engine** (`src/core/`): 71 | - `statusline.rs`: Main statusline generation orchestrator 72 | - `segments/`: Individual statusline segment implementations 73 | - `model.rs`: Claude model display with simplified names 74 | - `directory.rs`: Current workspace directory 75 | - `git.rs`: Git branch, status, and tracking information 76 | - `usage.rs`: Token usage tracking from transcripts 77 | - `cost.rs`: Real-time cost calculation and billing blocks 78 | - `burn_rate.rs`: Token consumption rate monitoring 79 | - `update.rs`: Self-update notifications 80 | 81 | 4. **Billing System** (`src/billing/`): 82 | - `calculator.rs`: Cost calculation engine 83 | - `pricing.rs`: Claude model pricing data 84 | - `block.rs`: 5-hour billing block detection algorithm 85 | - `types.rs`: Billing-related data structures 86 | 87 | 5. **Utilities** (`src/utils/`): 88 | - `data_loader.rs`: Claude Code data file parsing 89 | - `transcript.rs`: Conversation transcript analysis 90 | 91 | 6. **Self-Update** (`src/updater.rs`): GitHub release checking and binary updates 92 | 93 | ### Data Flow 94 | 95 | 1. Main entry point reads JSON input from stdin (Claude Code integration) 96 | 2. ConfigLoader assembles configuration from defaults + environment variables 97 | 3. StatusLineGenerator creates segment instances based on config 98 | 4. Each segment renders its content using the input data 99 | 5. Final statusline is assembled with ANSI color codes and separators 100 | 101 | ### Environment Variables 102 | 103 | - `CCLINE_DISABLE_COST=1`: Disables cost and burn rate segments 104 | - `CCLINE_SHOW_TIMING=1`: Shows performance timing for debugging 105 | 106 | ### Key Features Implementation 107 | 108 | - **Git Integration**: Uses git commands to detect branch, status, and remote tracking 109 | - **Cost Tracking**: Implements ccusage-compatible pricing and billing block algorithms 110 | - **Performance**: Rust native implementation with <50ms startup time 111 | - **Unicode Support**: Uses Nerd Font icons for visual elements 112 | - **Cross-Platform**: Supports Linux, macOS, and Windows binaries 113 | 114 | ### Testing Strategy 115 | 116 | Tests should focus on: 117 | - Segment rendering with various input scenarios 118 | - Configuration loading and validation 119 | - Billing calculation accuracy 120 | - CLI argument parsing 121 | - Error handling for missing dependencies (git, transcript files) 122 | 123 | ### Dependencies 124 | 125 | - Core: `serde`, `clap`, `toml`, `chrono`, `tokio`, `reqwest` 126 | - Optional features: `ureq`, `semver`, `dirs` (for self-update) 127 | - Build tools: Standard Rust toolchain (rustc, cargo) 128 | - Runtime: Git 1.5+ for git segment functionality 129 | 130 | ### Release Process 131 | 132 | 1. Update version in `Cargo.toml` 133 | 2. Build release binaries for all platforms: `cargo build --release` 134 | 3. Test binary functionality across platforms 135 | 4. Package binaries as platform-specific archives 136 | 5. Create GitHub release with appropriate assets 137 | 138 | ## Integration Notes 139 | 140 | This tool is designed specifically for Claude Code statusline integration. It expects: 141 | - JSON input via stdin containing model, directory, and git information 142 | - Nerd Font support in the terminal for proper icon display 143 | - Access to Claude Code transcript files for usage/cost analysis 144 | - Git repository context for git-related segments -------------------------------------------------------------------------------- /src/core/segments/cost.rs: -------------------------------------------------------------------------------- 1 | use super::Segment; 2 | use crate::billing::{ 3 | block::{find_active_block, identify_session_blocks_with_overrides}, 4 | calculator::{calculate_daily_total, calculate_session_cost, format_remaining_time}, 5 | ModelPricing, 6 | }; 7 | use crate::config::InputData; 8 | use crate::utils::{data_loader::DataLoader, transcript::extract_session_id}; 9 | use std::time::Instant; 10 | 11 | pub struct CostSegment { 12 | enabled: bool, 13 | show_timing: bool, 14 | } 15 | 16 | impl CostSegment { 17 | pub fn new(enabled: bool) -> Self { 18 | Self { 19 | enabled, 20 | show_timing: std::env::var("CCLINE_SHOW_TIMING").is_ok(), 21 | } 22 | } 23 | 24 | fn render_with_pricing(&self, input: &InputData) -> String { 25 | // Performance timing 26 | let start = Instant::now(); 27 | let mut timings = Vec::new(); 28 | 29 | // 1. Load all project data 30 | let load_start = Instant::now(); 31 | let data_loader = DataLoader::new(); 32 | let mut all_entries = data_loader.load_all_projects(); 33 | timings.push(("L", load_start.elapsed().as_millis())); 34 | 35 | // 2. Get pricing data (create a runtime to handle async) 36 | let pricing_start = Instant::now(); 37 | let pricing_map = { 38 | let rt = tokio::runtime::Runtime::new().unwrap(); 39 | rt.block_on(async { ModelPricing::get_pricing_with_fallback().await }) 40 | }; 41 | timings.push(("P", pricing_start.elapsed().as_millis())); 42 | 43 | // 3. Calculate costs for all entries 44 | let calc_start = Instant::now(); 45 | for entry in &mut all_entries { 46 | if let Some(pricing) = ModelPricing::get_model_pricing(&pricing_map, &entry.model) { 47 | entry.cost = Some(pricing.calculate_cost(entry)); 48 | } 49 | } 50 | timings.push(("C", calc_start.elapsed().as_millis())); 51 | 52 | // 4. Calculate session and daily costs 53 | let analyze_start = Instant::now(); 54 | let transcript_path = std::path::Path::new(&input.transcript_path); 55 | let session_id = extract_session_id(transcript_path); 56 | let session_cost = calculate_session_cost(&all_entries, &session_id, &pricing_map); 57 | let daily_total = calculate_daily_total(&all_entries, &pricing_map); 58 | timings.push(("A", analyze_start.elapsed().as_millis())); 59 | 60 | // 5. Calculate dynamic blocks with override support 61 | let block_start = Instant::now(); 62 | let blocks = identify_session_blocks_with_overrides(&all_entries); 63 | let active_block = find_active_block(&blocks); 64 | timings.push(("B", block_start.elapsed().as_millis())); 65 | 66 | // Format basic output 67 | let cost_display = match active_block { 68 | Some(block) => format!( 69 | "\u{f155} ${:.2} session · ${:.2} today · ${:.2} block ({})", 70 | session_cost, 71 | daily_total, 72 | block.cost, 73 | format_remaining_time(block.remaining_minutes) 74 | ), 75 | None => format!( 76 | "\u{f155} ${:.2} session · ${:.2} today · No active block", 77 | session_cost, daily_total 78 | ), 79 | }; 80 | 81 | // Add performance timing if enabled 82 | if self.show_timing { 83 | let total_ms = start.elapsed().as_millis(); 84 | let timing_str = format!( 85 | " [{}ms: L{}|P{}|C{}|A{}|B{}]", 86 | total_ms, 87 | timings[0].1, // Load 88 | timings[1].1, // Pricing 89 | timings[2].1, // Calculate 90 | timings[3].1, // Analyze 91 | timings[4].1 // Block 92 | ); 93 | format!("{}{}", cost_display, timing_str) 94 | } else { 95 | cost_display 96 | } 97 | } 98 | } 99 | 100 | impl Segment for CostSegment { 101 | fn render(&self, input: &InputData) -> String { 102 | if !self.enabled { 103 | return String::new(); 104 | } 105 | 106 | // Handle potential errors gracefully 107 | match std::panic::catch_unwind(|| self.render_with_pricing(input)) { 108 | Ok(result) => result, 109 | Err(_) => { 110 | // Fallback display on error 111 | "\u{f155} $0.00 session · $0.00 today · Error loading data".to_string() 112 | } 113 | } 114 | } 115 | 116 | fn enabled(&self) -> bool { 117 | self.enabled 118 | } 119 | } 120 | 121 | #[cfg(test)] 122 | mod tests { 123 | use super::*; 124 | use crate::config::{Model, Workspace}; 125 | 126 | #[test] 127 | fn test_cost_segment_disabled() { 128 | let segment = CostSegment::new(false); 129 | let input = InputData { 130 | model: Model { 131 | display_name: "test-model".to_string(), 132 | }, 133 | workspace: Workspace { 134 | current_dir: "/test".to_string(), 135 | }, 136 | transcript_path: "/test/transcript.jsonl".to_string(), 137 | }; 138 | 139 | assert_eq!(segment.render(&input), ""); 140 | assert!(!segment.enabled()); 141 | } 142 | 143 | #[test] 144 | fn test_cost_segment_enabled() { 145 | let segment = CostSegment::new(true); 146 | assert!(segment.enabled()); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/billing/types.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | /// Session usage data aggregated from transcript files 5 | #[derive(Debug, Clone, Default)] 6 | pub struct SessionUsage { 7 | pub total_input_tokens: u32, 8 | pub total_output_tokens: u32, 9 | pub cache_creation_tokens: u32, 10 | pub cache_read_tokens: u32, 11 | pub entries: Vec, 12 | pub session_id: String, 13 | pub start_time: Option>, 14 | pub last_update: Option>, 15 | } 16 | 17 | /// Single usage record from a transcript entry 18 | #[derive(Debug, Clone)] 19 | pub struct UsageEntry { 20 | pub timestamp: DateTime, 21 | pub input_tokens: u32, 22 | pub output_tokens: u32, 23 | pub cache_creation_tokens: u32, 24 | pub cache_read_tokens: u32, 25 | pub model: String, 26 | pub cost: Option, // Optional until pricing is calculated 27 | pub session_id: String, 28 | } 29 | 30 | /// 5-hour billing block with dynamic start time support 31 | #[derive(Debug, Clone)] 32 | pub struct BillingBlock { 33 | pub start_time: DateTime, 34 | pub end_time: DateTime, 35 | pub cost: f64, 36 | pub remaining_minutes: i64, 37 | pub is_active: bool, 38 | pub session_count: usize, 39 | pub total_tokens: u32, 40 | /// Source of the block start time 41 | pub start_time_source: BlockStartSource, 42 | /// Whether this is a gap block (no activity) 43 | pub is_gap: bool, 44 | } 45 | 46 | /// Source of block start time 47 | #[derive(Debug, Clone, PartialEq)] 48 | pub enum BlockStartSource { 49 | /// Automatically determined from first activity 50 | Auto, 51 | /// Manually set by user override 52 | Manual, 53 | /// Fixed 5-hour system (legacy mode) 54 | Fixed, 55 | } 56 | 57 | /// Burn rate calculation 58 | #[derive(Debug, Clone)] 59 | pub struct BurnRate { 60 | pub tokens_per_minute: f64, 61 | pub tokens_per_minute_for_indicator: f64, // Excludes cache tokens 62 | pub cost_per_hour: f64, 63 | pub trend: BurnRateTrend, 64 | } 65 | 66 | /// Burn rate trend indicator 67 | #[derive(Debug, Clone, PartialEq)] 68 | pub enum BurnRateTrend { 69 | Rising, 70 | Falling, 71 | Stable, 72 | } 73 | 74 | /// Burn rate thresholds for indicator display 75 | #[derive(Debug, Clone)] 76 | pub struct BurnRateThresholds { 77 | pub high: f64, // Default 5000 tokens/minute 78 | pub medium: f64, // Default 2000 tokens/minute 79 | } 80 | 81 | impl Default for BurnRateThresholds { 82 | fn default() -> Self { 83 | Self { 84 | high: 5000.0, 85 | medium: 2000.0, 86 | } 87 | } 88 | } 89 | 90 | impl BurnRateThresholds { 91 | /// Create thresholds from environment variables 92 | pub fn from_env() -> Self { 93 | let mut thresholds = Self::default(); 94 | 95 | if let Ok(high) = std::env::var("CCLINE_BURN_HIGH") { 96 | if let Ok(value) = high.parse::() { 97 | thresholds.high = value; 98 | } 99 | } 100 | 101 | if let Ok(medium) = std::env::var("CCLINE_BURN_MEDIUM") { 102 | if let Ok(value) = medium.parse::() { 103 | thresholds.medium = value; 104 | } 105 | } 106 | 107 | thresholds 108 | } 109 | } 110 | 111 | /// Model pricing information 112 | #[derive(Debug, Clone, Serialize, Deserialize)] 113 | pub struct ModelPricing { 114 | pub model_name: String, 115 | pub input_cost_per_1k: f64, 116 | pub output_cost_per_1k: f64, 117 | pub cache_creation_cost_per_1k: f64, 118 | pub cache_read_cost_per_1k: f64, 119 | } 120 | 121 | impl ModelPricing { 122 | /// Calculate cost for a usage entry 123 | pub fn calculate_cost(&self, entry: &UsageEntry) -> f64 { 124 | let input_cost = (entry.input_tokens as f64 / 1000.0) * self.input_cost_per_1k; 125 | let output_cost = (entry.output_tokens as f64 / 1000.0) * self.output_cost_per_1k; 126 | let cache_creation_cost = 127 | (entry.cache_creation_tokens as f64 / 1000.0) * self.cache_creation_cost_per_1k; 128 | let cache_read_cost = 129 | (entry.cache_read_tokens as f64 / 1000.0) * self.cache_read_cost_per_1k; 130 | 131 | input_cost + output_cost + cache_creation_cost + cache_read_cost 132 | } 133 | } 134 | 135 | impl SessionUsage { 136 | /// Calculate total cost given pricing 137 | pub fn calculate_cost(&self, pricing: &ModelPricing) -> f64 { 138 | let input_cost = (self.total_input_tokens as f64 / 1000.0) * pricing.input_cost_per_1k; 139 | let output_cost = (self.total_output_tokens as f64 / 1000.0) * pricing.output_cost_per_1k; 140 | let cache_creation_cost = 141 | (self.cache_creation_tokens as f64 / 1000.0) * pricing.cache_creation_cost_per_1k; 142 | let cache_read_cost = 143 | (self.cache_read_tokens as f64 / 1000.0) * pricing.cache_read_cost_per_1k; 144 | 145 | input_cost + output_cost + cache_creation_cost + cache_read_cost 146 | } 147 | 148 | /// Get total tokens (all types) 149 | pub fn total_tokens(&self) -> u32 { 150 | self.total_input_tokens 151 | + self.total_output_tokens 152 | + self.cache_creation_tokens 153 | + self.cache_read_tokens 154 | } 155 | } 156 | 157 | impl BillingBlock { 158 | /// Check if the block is currently active 159 | pub fn is_active(&self) -> bool { 160 | let now = Utc::now(); 161 | now >= self.start_time && now <= self.end_time 162 | } 163 | 164 | /// Calculate remaining minutes in the block 165 | pub fn remaining_minutes(&self) -> i64 { 166 | let now = Utc::now(); 167 | if now > self.end_time { 168 | return 0; 169 | } 170 | (self.end_time - now).num_minutes() 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # CCometixLine 2 | 3 | [English](README.md) | [中文](README.zh.md) 4 | 5 | 基于 Rust 的高性能 Claude Code 状态栏工具,集成 Git 信息和实时使用量跟踪。 6 | 7 | ![Language:Rust](https://img.shields.io/static/v1?label=Language&message=Rust&color=orange&style=flat-square) 8 | ![License:MIT](https://img.shields.io/static/v1?label=License&message=MIT&color=blue&style=flat-square) 9 | 10 | ## 截图 11 | 12 | ![CCometixLine](assets/img1.png) 13 | 14 | 状态栏显示:模型 | 目录 | Git 分支状态 | 使用量 | 成本统计 | 燃烧率 15 | 16 | ## 特性 17 | 18 | - **高性能** Rust 原生速度 19 | - **Git 集成** 显示分支、状态和跟踪信息 20 | - **模型显示** 简化的 Claude 模型名称 21 | - **使用量跟踪** 基于转录文件分析 22 | - **成本追踪** 显示会话、日常和计费块统计信息 23 | - **燃烧率监控** 实时消耗模式监控 24 | - **目录显示** 显示当前工作空间 25 | - **简洁设计** 使用 Nerd Font 图标 26 | - **简单配置** 通过命令行选项配置 27 | - **环境变量控制** 功能自定义选项 28 | 29 | ## 安装 30 | 31 | 从 [Releases](https://github.com/Haleclipse/CCometixLine/releases) 下载: 32 | 33 | ### Linux 34 | 35 | #### 选项 1: 动态链接版本(推荐) 36 | ```bash 37 | mkdir -p ~/.claude/ccline 38 | wget https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-linux-x64.tar.gz 39 | tar -xzf ccline-linux-x64.tar.gz 40 | cp ccline ~/.claude/ccline/ 41 | chmod +x ~/.claude/ccline/ccline 42 | ``` 43 | *系统要求: Ubuntu 22.04+, CentOS 9+, Debian 11+, RHEL 9+ (glibc 2.35+)* 44 | 45 | #### 选项 2: 静态链接版本(通用兼容) 46 | ```bash 47 | mkdir -p ~/.claude/ccline 48 | wget https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-linux-x64-static.tar.gz 49 | tar -xzf ccline-linux-x64-static.tar.gz 50 | cp ccline ~/.claude/ccline/ 51 | chmod +x ~/.claude/ccline/ccline 52 | ``` 53 | *适用于任何 Linux 发行版(静态链接,无依赖)* 54 | 55 | ### macOS (Intel) 56 | 57 | ```bash 58 | mkdir -p ~/.claude/ccline 59 | wget https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-macos-x64.tar.gz 60 | tar -xzf ccline-macos-x64.tar.gz 61 | cp ccline ~/.claude/ccline/ 62 | chmod +x ~/.claude/ccline/ccline 63 | ``` 64 | 65 | ### macOS (Apple Silicon) 66 | 67 | ```bash 68 | mkdir -p ~/.claude/ccline 69 | wget https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-macos-arm64.tar.gz 70 | tar -xzf ccline-macos-arm64.tar.gz 71 | cp ccline ~/.claude/ccline/ 72 | chmod +x ~/.claude/ccline/ccline 73 | ``` 74 | 75 | ### Windows 76 | 77 | ```powershell 78 | # 创建目录并下载 79 | New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.claude\ccline" 80 | Invoke-WebRequest -Uri "https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-windows-x64.zip" -OutFile "ccline-windows-x64.zip" 81 | Expand-Archive -Path "ccline-windows-x64.zip" -DestinationPath "." 82 | Move-Item "ccline.exe" "$env:USERPROFILE\.claude\ccline\" 83 | ``` 84 | 85 | ### 从源码构建 86 | 87 | ```bash 88 | git clone https://github.com/Haleclipse/CCometixLine.git 89 | cd CCometixLine 90 | cargo build --release 91 | cp target/release/ccometixline ~/.claude/ccline/ccline 92 | ``` 93 | 94 | ### Claude Code 配置 95 | 96 | 添加到 Claude Code `settings.json`: 97 | 98 | **Linux/macOS:** 99 | ```json 100 | { 101 | "statusLine": { 102 | "type": "command", 103 | "command": "~/.claude/ccline/ccline", 104 | "padding": 0 105 | } 106 | } 107 | ``` 108 | 109 | **Windows:** 110 | ```json 111 | { 112 | "statusLine": { 113 | "type": "command", 114 | "command": "%USERPROFILE%\\.claude\\ccline\\ccline.exe", 115 | "padding": 0 116 | } 117 | } 118 | ``` 119 | 120 | ## 使用 121 | 122 | ```bash 123 | # 基础使用 (显示所有启用的段落) 124 | ccline 125 | 126 | # 显示帮助 127 | ccline --help 128 | 129 | # 打印默认配置 130 | ccline --print-config 131 | 132 | # TUI 配置模式 (计划中) 133 | ccline --configure 134 | 135 | # 计费块管理 136 | ccline --set-block-start <时间> # 设置当天计费块开始时间 137 | ccline --clear-block-start # 清除计费块开始时间设置 138 | ccline --show-block-status # 显示当前计费块状态 139 | ``` 140 | 141 | ### 计费块同步功能 142 | 143 | 解决同一账号在多设备间切换时计费块不同步的问题: 144 | 145 | ```bash 146 | # 在设备A上设置块开始时间为上午10点 147 | ccline --set-block-start 10 148 | 149 | # 支持的时间格式: 150 | ccline --set-block-start 10 # 10:00 (24小时制) 151 | ccline --set-block-start 10:30 # 10:30 152 | ccline --set-block-start "10:30" # 带引号也可以 153 | 154 | # 查看当前设置 155 | ccline --show-block-status 156 | 157 | # 清除设置,恢复自动计算 158 | ccline --clear-block-start 159 | ``` 160 | 161 | ## 默认段落 162 | 163 | 显示:`模型 | 目录 | Git 分支状态 | 使用量 | 成本统计 | 燃烧率` 164 | 165 | ### 模型显示 166 | 167 | 显示简化的 Claude 模型名称: 168 | - `claude-3-5-sonnet` → `Sonnet 3.5` 169 | - `claude-4-sonnet` → `Sonnet 4` 170 | 171 | ### 目录显示 172 | 173 | 显示当前工作空间目录和文件夹图标。 174 | 175 | ### Git 状态指示器 176 | 177 | - 带 Nerd Font 图标的分支名 178 | - 状态:`✓` 清洁,`●` 有更改,`⚠` 冲突 179 | - 远程跟踪:`↑n` 领先,`↓n` 落后 180 | 181 | ### 使用量显示 182 | 183 | 基于转录文件分析的令牌使用百分比,包含上下文限制跟踪。 184 | 185 | ### 成本统计 186 | 187 | 实时成本追踪,显示会话、日常和计费块信息: 188 | - **会话成本**:当前 Claude Code 会话的成本 189 | - **日常总计**:今日所有会话的总成本 190 | - **计费块**:5小时计费周期及剩余时间(支持手动同步) 191 | 192 | #### 动态计费块算法 193 | 194 | 采用与 ccusage 相同的双条件触发算法: 195 | - 自动检测活动开始时间,创建5小时计费块 196 | - 当活动间隔超过5小时时自动开始新块 197 | - 支持手动设置开始时间以在多设备间同步 198 | 199 | ### 燃烧率监控 200 | 201 | 实时令牌消耗率监控和视觉指示器: 202 | - 🔥 高燃烧率 (>5000 tokens/分钟) 203 | - ⚡ 中等燃烧率 (2000-5000 tokens/分钟) 204 | - 📊 正常燃烧率 (<2000 tokens/分钟) 205 | - 显示每小时成本预测 206 | 207 | ## 环境变量 208 | 209 | ### 成本功能控制 210 | 211 | - `CCLINE_DISABLE_COST=1` - 同时禁用成本统计和燃烧率监控 212 | - 设置时:仅显示核心段落(模型 | 目录 | Git | 使用量) 213 | - 未设置时:显示所有段落包括成本追踪 214 | 215 | ### 性能调优 216 | 217 | - `CCLINE_SHOW_TIMING=1` - 显示性能计时信息用于调试 218 | 219 | ## 配置 220 | 221 | 计划在未来版本中支持配置。当前为所有段落使用合理的默认值。 222 | 223 | ## 性能 224 | 225 | - **启动时间**:< 50ms(TypeScript 版本约 200ms) 226 | - **内存使用**:< 10MB(Node.js 工具约 25MB) 227 | - **二进制大小**:约 2MB 优化版本 228 | 229 | ## 系统要求 230 | 231 | - **Git**: 版本 1.5+ (推荐 Git 2.22+ 以获得更好的分支检测) 232 | - **终端**: 必须支持 Nerd Font 图标正常显示 233 | - 安装 [Nerd Font](https://www.nerdfonts.com/) 字体 234 | - 中文用户推荐: [Maple Font](https://github.com/subframe7536/maple-font) (支持中文的 Nerd Font) 235 | - 在终端中配置使用该字体 236 | - **Claude Code**: 用于状态栏集成 237 | 238 | ## 开发 239 | 240 | ```bash 241 | # 构建开发版本 242 | cargo build 243 | 244 | # 运行测试 245 | cargo test 246 | 247 | # 构建优化版本 248 | cargo build --release 249 | ``` 250 | 251 | ## 路线图 252 | 253 | - [ ] TOML 配置文件支持 254 | - [ ] TUI 配置界面 255 | - [ ] 自定义主题 256 | - [ ] 插件系统 257 | - [ ] 跨平台二进制文件 258 | 259 | ## 致谢 260 | 261 | ### ccusage 集成 262 | 263 | 成本追踪功能基于 [ccusage](https://github.com/ryoppippi/ccusage) 项目的统计方法和定价数据实现。 264 | 265 | ## 贡献 266 | 267 | 欢迎贡献!请随时提交 issue 或 pull request。 268 | 269 | ## 许可证 270 | 271 | 本项目采用 [MIT 许可证](LICENSE)。 272 | 273 | ## Star History 274 | 275 | [![Star History Chart](https://api.star-history.com/svg?repos=Haleclipse/CCometixLine&type=Date)](https://star-history.com/#Haleclipse/CCometixLine&Date) -------------------------------------------------------------------------------- /examples/test_litellm_fetch.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::collections::HashMap; 3 | 4 | // Test struct matching our current implementation (with Option fields) 5 | #[derive(Debug, Clone, Deserialize)] 6 | pub struct LiteLLMPricing { 7 | pub input_cost_per_token: Option, 8 | pub output_cost_per_token: Option, 9 | #[serde(default)] 10 | pub cache_creation_input_token_cost: Option, 11 | #[serde(default)] 12 | pub cache_read_input_token_cost: Option, 13 | } 14 | 15 | // Test with extra fields allowed 16 | #[derive(Debug, Clone, Deserialize)] 17 | pub struct LiteLLMPricingFlexible { 18 | pub input_cost_per_token: Option, 19 | pub output_cost_per_token: Option, 20 | #[serde(default)] 21 | pub cache_creation_input_token_cost: Option, 22 | #[serde(default)] 23 | pub cache_read_input_token_cost: Option, 24 | // Catch all extra fields 25 | #[serde(flatten)] 26 | pub extra: HashMap, 27 | } 28 | 29 | #[tokio::main] 30 | async fn main() { 31 | println!("Testing LiteLLM Pricing Fetch"); 32 | println!("==============================\n"); 33 | 34 | // Test 1: Fetch the actual data 35 | println!("1. Fetching from LiteLLM..."); 36 | let response = match reqwest::get("https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json").await { 37 | Ok(r) => r, 38 | Err(e) => { 39 | println!(" ✗ Failed to fetch: {}", e); 40 | return; 41 | } 42 | }; 43 | println!(" ✓ Successfully fetched data"); 44 | 45 | // Test 2: Get response text 46 | let text = match response.text().await { 47 | Ok(t) => t, 48 | Err(e) => { 49 | println!(" ✗ Failed to get text: {}", e); 50 | return; 51 | } 52 | }; 53 | println!(" ✓ Got response text ({} bytes)", text.len()); 54 | 55 | // Test 3: Parse as generic JSON first 56 | println!("\n2. Parsing as generic JSON..."); 57 | let json_value: serde_json::Value = match serde_json::from_str(&text) { 58 | Ok(v) => v, 59 | Err(e) => { 60 | println!(" ✗ Failed to parse as JSON: {}", e); 61 | println!(" First 500 chars: {}", &text[..500.min(text.len())]); 62 | return; 63 | } 64 | }; 65 | println!(" ✓ Successfully parsed as JSON"); 66 | 67 | // Test 4: Check structure 68 | if let Some(obj) = json_value.as_object() { 69 | println!(" Found {} top-level keys", obj.len()); 70 | 71 | // Find Claude models 72 | let claude_models: Vec<_> = obj.keys().filter(|k| k.contains("claude")).collect(); 73 | println!(" Found {} Claude models", claude_models.len()); 74 | 75 | // Show first Claude model 76 | if let Some(model_name) = claude_models.first() { 77 | println!("\n3. Examining model: {}", model_name); 78 | if let Some(model_data) = obj.get(*model_name) { 79 | // Try to parse this specific model 80 | match serde_json::from_value::(model_data.clone()) { 81 | Ok(pricing) => { 82 | println!(" ✓ Successfully parsed with strict struct:"); 83 | println!(" Input cost: {:?}", pricing.input_cost_per_token); 84 | println!(" Output cost: {:?}", pricing.output_cost_per_token); 85 | } 86 | Err(e) => { 87 | println!(" ✗ Failed with strict struct: {}", e); 88 | } 89 | } 90 | 91 | // Try with flexible struct 92 | match serde_json::from_value::(model_data.clone()) { 93 | Ok(pricing) => { 94 | println!(" ✓ Successfully parsed with flexible struct:"); 95 | println!(" Input cost: {:?}", pricing.input_cost_per_token); 96 | println!(" Output cost: {:?}", pricing.output_cost_per_token); 97 | println!(" Extra fields: {}", pricing.extra.len()); 98 | } 99 | Err(e) => { 100 | println!(" ✗ Failed with flexible struct: {}", e); 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | // Test 5: Try parsing full HashMap 108 | println!("\n4. Parsing as HashMap..."); 109 | match serde_json::from_str::>(&text) { 110 | Ok(data) => { 111 | println!(" ✓ Successfully parsed!"); 112 | let claude_count = data.keys().filter(|k| k.contains("claude")).count(); 113 | println!(" Found {} Claude models in HashMap", claude_count); 114 | } 115 | Err(e) => { 116 | println!(" ✗ Failed to parse: {}", e); 117 | 118 | // Try to identify which field causes the issue 119 | if e.to_string().contains("unknown field") { 120 | println!(" Issue: Unknown field in response"); 121 | } else if e.to_string().contains("invalid type") { 122 | println!(" Issue: Type mismatch"); 123 | } 124 | } 125 | } 126 | 127 | // Test 6: Try with flexible struct 128 | println!("\n5. Parsing as HashMap..."); 129 | match serde_json::from_str::>(&text) { 130 | Ok(data) => { 131 | println!(" ✓ Successfully parsed with flexible struct!"); 132 | let claude_count = data.keys().filter(|k| k.contains("claude")).count(); 133 | println!(" Found {} Claude models", claude_count); 134 | 135 | // Show some pricing data for Claude models with valid pricing 136 | let mut shown = 0; 137 | for (name, pricing) in data.iter() { 138 | if name.contains("claude") 139 | && pricing.input_cost_per_token.is_some() 140 | && pricing.output_cost_per_token.is_some() 141 | && shown < 3 142 | { 143 | println!("\n Model: {}", name); 144 | println!( 145 | " - Input: ${:.6}/token", 146 | pricing.input_cost_per_token.unwrap() 147 | ); 148 | println!( 149 | " - Output: ${:.6}/token", 150 | pricing.output_cost_per_token.unwrap() 151 | ); 152 | shown += 1; 153 | } 154 | } 155 | } 156 | Err(e) => { 157 | println!(" ✗ Failed with flexible struct: {}", e); 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/core/segments/git.rs: -------------------------------------------------------------------------------- 1 | use super::Segment; 2 | use crate::config::InputData; 3 | use std::process::Command; 4 | 5 | #[derive(Debug)] 6 | pub struct GitInfo { 7 | pub branch: String, 8 | pub status: GitStatus, 9 | pub ahead: u32, 10 | pub behind: u32, 11 | pub sha: Option, 12 | } 13 | 14 | #[derive(Debug, PartialEq)] 15 | pub enum GitStatus { 16 | Clean, 17 | Dirty, 18 | Conflicts, 19 | } 20 | 21 | pub struct GitSegment { 22 | enabled: bool, 23 | show_sha: bool, 24 | } 25 | 26 | impl GitSegment { 27 | pub fn new(enabled: bool) -> Self { 28 | Self { 29 | enabled, 30 | show_sha: false, 31 | } 32 | } 33 | 34 | pub fn with_sha(mut self, show_sha: bool) -> Self { 35 | self.show_sha = show_sha; 36 | self 37 | } 38 | 39 | fn get_git_info(&self, working_dir: &str) -> Option { 40 | // First check if we're in a Git repository 41 | if !self.is_git_repository(working_dir) { 42 | return None; 43 | } 44 | 45 | let branch = self 46 | .get_branch(working_dir) 47 | .unwrap_or_else(|| "detached".to_string()); 48 | let status = self.get_status(working_dir); 49 | let (ahead, behind) = self.get_ahead_behind(working_dir); 50 | let sha = if self.show_sha { 51 | self.get_sha(working_dir) 52 | } else { 53 | None 54 | }; 55 | 56 | Some(GitInfo { 57 | branch, 58 | status, 59 | ahead, 60 | behind, 61 | sha, 62 | }) 63 | } 64 | 65 | fn is_git_repository(&self, working_dir: &str) -> bool { 66 | Command::new("git") 67 | .args(["rev-parse", "--git-dir"]) 68 | .current_dir(working_dir) 69 | .output() 70 | .map(|output| output.status.success()) 71 | .unwrap_or(false) 72 | } 73 | 74 | fn get_branch(&self, working_dir: &str) -> Option { 75 | // Try modern Git 2.22+ command first 76 | if let Ok(output) = Command::new("git") 77 | .args(["branch", "--show-current"]) 78 | .current_dir(working_dir) 79 | .output() 80 | { 81 | if output.status.success() { 82 | let branch = String::from_utf8(output.stdout).ok()?.trim().to_string(); 83 | if !branch.is_empty() { 84 | return Some(branch); 85 | } 86 | } 87 | } 88 | 89 | // Fallback for older Git versions (< 2.22) 90 | if let Ok(output) = Command::new("git") 91 | .args(["symbolic-ref", "--short", "HEAD"]) 92 | .current_dir(working_dir) 93 | .output() 94 | { 95 | if output.status.success() { 96 | let branch = String::from_utf8(output.stdout).ok()?.trim().to_string(); 97 | if !branch.is_empty() { 98 | return Some(branch); 99 | } 100 | } 101 | } 102 | 103 | None 104 | } 105 | 106 | fn get_status(&self, working_dir: &str) -> GitStatus { 107 | let output = Command::new("git") 108 | .args(["status", "--porcelain"]) 109 | .current_dir(working_dir) 110 | .output(); 111 | 112 | match output { 113 | Ok(output) if output.status.success() => { 114 | let status_text = String::from_utf8(output.stdout).unwrap_or_default(); 115 | 116 | if status_text.trim().is_empty() { 117 | return GitStatus::Clean; 118 | } 119 | 120 | // Check for merge conflict markers 121 | if status_text.contains("UU") 122 | || status_text.contains("AA") 123 | || status_text.contains("DD") 124 | { 125 | GitStatus::Conflicts 126 | } else { 127 | GitStatus::Dirty 128 | } 129 | } 130 | _ => GitStatus::Clean, 131 | } 132 | } 133 | 134 | fn get_ahead_behind(&self, working_dir: &str) -> (u32, u32) { 135 | let ahead = self.get_commit_count(working_dir, "@{u}..HEAD"); 136 | let behind = self.get_commit_count(working_dir, "HEAD..@{u}"); 137 | (ahead, behind) 138 | } 139 | 140 | fn get_commit_count(&self, working_dir: &str, range: &str) -> u32 { 141 | let output = Command::new("git") 142 | .args(["rev-list", "--count", range]) 143 | .current_dir(working_dir) 144 | .output(); 145 | 146 | match output { 147 | Ok(output) if output.status.success() => String::from_utf8(output.stdout) 148 | .ok() 149 | .and_then(|s| s.trim().parse().ok()) 150 | .unwrap_or(0), 151 | _ => 0, 152 | } 153 | } 154 | 155 | fn get_sha(&self, working_dir: &str) -> Option { 156 | let output = Command::new("git") 157 | .args(["rev-parse", "--short=7", "HEAD"]) 158 | .current_dir(working_dir) 159 | .output() 160 | .ok()?; 161 | 162 | if output.status.success() { 163 | let sha = String::from_utf8(output.stdout).ok()?.trim().to_string(); 164 | if sha.is_empty() { 165 | None 166 | } else { 167 | Some(sha) 168 | } 169 | } else { 170 | None 171 | } 172 | } 173 | 174 | fn format_git_status(&self, info: &GitInfo) -> String { 175 | let mut parts = Vec::new(); 176 | 177 | // Branch name with Nerd Font branch icon 178 | parts.push(format!("\u{f02a2} {}", info.branch)); 179 | 180 | // Status indicators using simple Unicode symbols 181 | match info.status { 182 | GitStatus::Clean => parts.push("✓".to_string()), 183 | GitStatus::Dirty => parts.push("●".to_string()), 184 | GitStatus::Conflicts => parts.push("⚠".to_string()), 185 | } 186 | 187 | // Remote tracking status with arrows 188 | if info.ahead > 0 { 189 | parts.push(format!("↑{}", info.ahead)); 190 | } 191 | if info.behind > 0 { 192 | parts.push(format!("↓{}", info.behind)); 193 | } 194 | 195 | // Short SHA hash 196 | if let Some(ref sha) = info.sha { 197 | parts.push(sha.clone()); 198 | } 199 | 200 | parts.join(" ") 201 | } 202 | } 203 | 204 | impl Segment for GitSegment { 205 | fn render(&self, input: &InputData) -> String { 206 | if !self.enabled { 207 | return String::new(); 208 | } 209 | 210 | match self.get_git_info(&input.workspace.current_dir) { 211 | Some(git_info) => self.format_git_status(&git_info), 212 | None => String::new(), // Not in a Git repository 213 | } 214 | } 215 | 216 | fn enabled(&self) -> bool { 217 | self.enabled 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/config/types.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | 4 | #[derive(Debug, Clone, Deserialize, Serialize)] 5 | pub struct Config { 6 | pub theme: String, 7 | pub segments: SegmentsConfig, 8 | } 9 | 10 | #[derive(Debug, Clone, Deserialize, Serialize)] 11 | pub struct SegmentsConfig { 12 | pub directory: bool, 13 | pub git: bool, 14 | pub model: bool, 15 | pub usage: bool, 16 | #[serde(default = "default_true")] 17 | pub cost: bool, 18 | #[serde(default = "default_true")] 19 | pub burn_rate: bool, 20 | } 21 | 22 | fn default_true() -> bool { 23 | true 24 | } 25 | 26 | // Data structures compatible with existing main.rs 27 | #[derive(Deserialize)] 28 | pub struct Model { 29 | pub display_name: String, 30 | } 31 | 32 | #[derive(Deserialize)] 33 | pub struct Workspace { 34 | pub current_dir: String, 35 | } 36 | 37 | #[derive(Deserialize)] 38 | pub struct InputData { 39 | pub model: Model, 40 | pub workspace: Workspace, 41 | pub transcript_path: String, 42 | } 43 | 44 | // OpenAI-style nested token details 45 | #[derive(Debug, Clone, Deserialize, Serialize, Default)] 46 | pub struct PromptTokensDetails { 47 | #[serde(default)] 48 | pub cached_tokens: Option, 49 | #[serde(default)] 50 | pub audio_tokens: Option, 51 | } 52 | 53 | // Raw usage data from different LLM providers (flexible parsing) 54 | #[derive(Debug, Clone, Deserialize, Serialize, Default)] 55 | pub struct RawUsage { 56 | // Common input token naming variants 57 | #[serde(default, alias = "prompt_tokens")] 58 | pub input_tokens: Option, 59 | 60 | // Common output token naming variants 61 | #[serde(default, alias = "completion_tokens")] 62 | pub output_tokens: Option, 63 | 64 | // Total tokens (some providers only provide this) 65 | #[serde(default)] 66 | pub total_tokens: Option, 67 | 68 | // Anthropic-style cache fields 69 | #[serde(default, alias = "cache_creation_prompt_tokens")] 70 | pub cache_creation_input_tokens: Option, 71 | 72 | #[serde(default, alias = "cache_read_prompt_tokens")] 73 | pub cache_read_input_tokens: Option, 74 | 75 | // OpenAI-style nested details 76 | #[serde(default)] 77 | pub prompt_tokens_details: Option, 78 | 79 | // Completion token details (OpenAI) 80 | #[serde(default)] 81 | pub completion_tokens_details: Option>, 82 | 83 | // Catch unknown fields for future compatibility and debugging 84 | #[serde(flatten, skip_serializing)] 85 | pub extra: HashMap, 86 | } 87 | 88 | // Normalized internal representation after processing 89 | #[derive(Debug, Clone, Serialize, Default, PartialEq)] 90 | pub struct NormalizedUsage { 91 | pub input_tokens: u32, 92 | pub output_tokens: u32, 93 | pub total_tokens: u32, 94 | pub cache_creation_input_tokens: u32, 95 | pub cache_read_input_tokens: u32, 96 | 97 | // Metadata for debugging and analysis 98 | pub calculation_source: String, 99 | pub raw_data_available: Vec, 100 | } 101 | 102 | impl NormalizedUsage { 103 | /// Get tokens that count toward context window 104 | /// This includes all tokens that consume context window space 105 | /// Output tokens from this turn will become input tokens in the next turn 106 | pub fn context_tokens(&self) -> u32 { 107 | self.input_tokens 108 | + self.cache_creation_input_tokens 109 | + self.cache_read_input_tokens 110 | + self.output_tokens 111 | } 112 | 113 | /// Get total tokens for cost calculation 114 | /// Priority: use total_tokens if available, otherwise sum all components 115 | pub fn total_for_cost(&self) -> u32 { 116 | if self.total_tokens > 0 { 117 | self.total_tokens 118 | } else { 119 | self.input_tokens 120 | + self.output_tokens 121 | + self.cache_creation_input_tokens 122 | + self.cache_read_input_tokens 123 | } 124 | } 125 | 126 | /// Get the most appropriate token count for general display 127 | /// For OpenAI format: use total_tokens directly 128 | /// For Anthropic format: use context_tokens (input + cache) 129 | pub fn display_tokens(&self) -> u32 { 130 | // For Claude/Anthropic format: prefer input-related tokens for context window display 131 | let context = self.context_tokens(); 132 | if context > 0 { 133 | return context; 134 | } 135 | 136 | // For OpenAI format: use total_tokens when no input breakdown available 137 | if self.total_tokens > 0 { 138 | return self.total_tokens; 139 | } 140 | 141 | // Fallback to any available tokens 142 | self.input_tokens.max(self.output_tokens) 143 | } 144 | } 145 | 146 | impl RawUsage { 147 | /// Convert raw usage data to normalized format with intelligent token inference 148 | pub fn normalize(self) -> NormalizedUsage { 149 | let mut result = NormalizedUsage::default(); 150 | let mut sources = Vec::new(); 151 | 152 | // Collect available raw data fields 153 | let mut available_fields = Vec::new(); 154 | if self.input_tokens.is_some() { 155 | available_fields.push("input_tokens".to_string()); 156 | } 157 | if self.output_tokens.is_some() { 158 | available_fields.push("output_tokens".to_string()); 159 | } 160 | if self.total_tokens.is_some() { 161 | available_fields.push("total_tokens".to_string()); 162 | } 163 | if self.cache_creation_input_tokens.is_some() { 164 | available_fields.push("cache_creation".to_string()); 165 | } 166 | if self.cache_read_input_tokens.is_some() { 167 | available_fields.push("cache_read".to_string()); 168 | } 169 | 170 | result.raw_data_available = available_fields; 171 | 172 | // Extract directly available values 173 | let input = self.input_tokens.unwrap_or(0); 174 | let output = self.output_tokens.unwrap_or(0); 175 | let total = self.total_tokens.unwrap_or(0); 176 | 177 | // Handle cache tokens with fallback to OpenAI nested format 178 | let cache_read = self 179 | .cache_read_input_tokens 180 | .or_else(|| { 181 | self.prompt_tokens_details 182 | .as_ref() 183 | .and_then(|d| d.cached_tokens) 184 | }) 185 | .unwrap_or(0); 186 | 187 | let cache_creation = self.cache_creation_input_tokens.unwrap_or(0); 188 | 189 | // Token calculation logic - prioritize total_tokens for OpenAI format 190 | let final_total = if total > 0 { 191 | sources.push("total_tokens_direct".to_string()); 192 | total 193 | } else if input > 0 || output > 0 || cache_read > 0 || cache_creation > 0 { 194 | let calculated = input + output + cache_read + cache_creation; 195 | sources.push("total_from_components".to_string()); 196 | calculated 197 | } else { 198 | 0 199 | }; 200 | 201 | // Final assignment 202 | result.input_tokens = input; 203 | result.output_tokens = output; 204 | result.total_tokens = final_total; 205 | result.cache_creation_input_tokens = cache_creation; 206 | result.cache_read_input_tokens = cache_read; 207 | result.calculation_source = sources.join("+"); 208 | 209 | result 210 | } 211 | } 212 | 213 | // Legacy alias for backward compatibility 214 | pub type Usage = RawUsage; 215 | 216 | #[derive(Deserialize)] 217 | pub struct Message { 218 | #[serde(default)] 219 | pub id: Option, 220 | pub usage: Option, 221 | pub model: Option, 222 | } 223 | 224 | #[derive(Deserialize)] 225 | pub struct TranscriptEntry { 226 | pub r#type: Option, 227 | pub message: Option, 228 | #[serde(default, alias = "requestId")] 229 | pub request_id: Option, 230 | #[serde(default)] 231 | pub timestamp: Option, 232 | } 233 | -------------------------------------------------------------------------------- /src/billing/calculator.rs: -------------------------------------------------------------------------------- 1 | use crate::billing::{BillingBlock, BurnRate, BurnRateTrend, ModelPricing, UsageEntry}; 2 | use chrono::{Duration, Local, Utc}; 3 | use std::collections::HashMap; 4 | 5 | /// Calculate cost for a single usage entry 6 | pub fn calculate_entry_cost(entry: &UsageEntry, pricing: &ModelPricing) -> f64 { 7 | let input_cost = (entry.input_tokens as f64 / 1000.0) * pricing.input_cost_per_1k; 8 | let output_cost = (entry.output_tokens as f64 / 1000.0) * pricing.output_cost_per_1k; 9 | let cache_creation_cost = 10 | (entry.cache_creation_tokens as f64 / 1000.0) * pricing.cache_creation_cost_per_1k; 11 | let cache_read_cost = 12 | (entry.cache_read_tokens as f64 / 1000.0) * pricing.cache_read_cost_per_1k; 13 | 14 | input_cost + output_cost + cache_creation_cost + cache_read_cost 15 | } 16 | 17 | /// Calculate total cost for a session 18 | pub fn calculate_session_cost( 19 | entries: &[UsageEntry], 20 | session_id: &str, 21 | pricing_map: &HashMap, 22 | ) -> f64 { 23 | entries 24 | .iter() 25 | .filter(|e| e.session_id == session_id) 26 | .filter_map(|entry| { 27 | // Find pricing for this model 28 | ModelPricing::get_model_pricing(pricing_map, &entry.model) 29 | .map(|pricing| calculate_entry_cost(entry, pricing)) 30 | }) 31 | .sum() 32 | } 33 | 34 | /// Calculate total cost for today 35 | pub fn calculate_daily_total( 36 | entries: &[UsageEntry], 37 | pricing_map: &HashMap, 38 | ) -> f64 { 39 | let today = Local::now().date_naive(); 40 | 41 | entries 42 | .iter() 43 | .filter(|e| e.timestamp.with_timezone(&Local).date_naive() == today) 44 | .filter_map(|entry| { 45 | // Find pricing for this model 46 | ModelPricing::get_model_pricing(pricing_map, &entry.model) 47 | .map(|pricing| calculate_entry_cost(entry, pricing)) 48 | }) 49 | .sum() 50 | } 51 | 52 | /// Calculate burn rate based on recent activity 53 | pub fn calculate_burn_rate(block: &BillingBlock, entries: &[UsageEntry]) -> Option { 54 | let now = Utc::now(); 55 | let five_minutes_ago = now - Duration::minutes(5); 56 | 57 | // Filter entries from the last 5 minutes within this block 58 | let recent_entries: Vec<&UsageEntry> = entries 59 | .iter() 60 | .filter(|e| { 61 | e.timestamp >= block.start_time 62 | && e.timestamp <= block.end_time 63 | && e.timestamp >= five_minutes_ago 64 | }) 65 | .collect(); 66 | 67 | if recent_entries.is_empty() { 68 | return None; 69 | } 70 | 71 | // Calculate time span 72 | let time_span = if recent_entries.len() == 1 { 73 | Duration::minutes(1) // Assume at least 1 minute for single entry 74 | } else { 75 | let first = recent_entries.first()?.timestamp; 76 | let last = recent_entries.last()?.timestamp; 77 | last - first 78 | }; 79 | 80 | let minutes = time_span.num_seconds() as f64 / 60.0; 81 | if minutes <= 0.0 { 82 | return None; 83 | } 84 | 85 | // Calculate total tokens (all types) 86 | let total_tokens: u32 = recent_entries 87 | .iter() 88 | .map(|e| e.input_tokens + e.output_tokens + e.cache_creation_tokens + e.cache_read_tokens) 89 | .sum(); 90 | 91 | // Calculate tokens excluding cache (for indicator thresholds) 92 | let non_cache_tokens: u32 = recent_entries 93 | .iter() 94 | .map(|e| e.input_tokens + e.output_tokens) 95 | .sum(); 96 | 97 | let tokens_per_minute = total_tokens as f64 / minutes; 98 | let tokens_per_minute_for_indicator = non_cache_tokens as f64 / minutes; 99 | 100 | // Calculate cost per hour (simplified - assumes same rate) 101 | let cost_per_hour = (block.cost / block.total_tokens as f64) * tokens_per_minute * 60.0; 102 | 103 | // Determine trend (simplified) 104 | let trend = if recent_entries.len() >= 2 { 105 | let mid_point = recent_entries.len() / 2; 106 | let first_half_tokens: u32 = recent_entries[..mid_point] 107 | .iter() 108 | .map(|e| { 109 | e.input_tokens + e.output_tokens + e.cache_creation_tokens + e.cache_read_tokens 110 | }) 111 | .sum(); 112 | let second_half_tokens: u32 = recent_entries[mid_point..] 113 | .iter() 114 | .map(|e| { 115 | e.input_tokens + e.output_tokens + e.cache_creation_tokens + e.cache_read_tokens 116 | }) 117 | .sum(); 118 | 119 | if second_half_tokens > first_half_tokens { 120 | BurnRateTrend::Rising 121 | } else if second_half_tokens < first_half_tokens { 122 | BurnRateTrend::Falling 123 | } else { 124 | BurnRateTrend::Stable 125 | } 126 | } else { 127 | BurnRateTrend::Stable 128 | }; 129 | 130 | Some(BurnRate { 131 | tokens_per_minute, 132 | tokens_per_minute_for_indicator, 133 | cost_per_hour, 134 | trend, 135 | }) 136 | } 137 | 138 | /// Format remaining time in human-readable format 139 | pub fn format_remaining_time(minutes: i64) -> String { 140 | if minutes <= 0 { 141 | return "expired".to_string(); 142 | } 143 | 144 | let hours = minutes / 60; 145 | let mins = minutes % 60; 146 | 147 | if hours > 0 { 148 | format!("{}h {}m", hours, mins) 149 | } else { 150 | format!("{}m", mins) 151 | } 152 | } 153 | 154 | #[cfg(test)] 155 | mod tests { 156 | use super::*; 157 | use chrono::Utc; 158 | 159 | #[test] 160 | fn test_calculate_entry_cost() { 161 | let entry = UsageEntry { 162 | timestamp: Utc::now(), 163 | input_tokens: 1000, 164 | output_tokens: 500, 165 | cache_creation_tokens: 100, 166 | cache_read_tokens: 50, 167 | model: "claude-3-5-sonnet".to_string(), 168 | cost: None, 169 | session_id: "test".to_string(), 170 | }; 171 | 172 | let pricing = ModelPricing { 173 | model_name: "claude-3-5-sonnet".to_string(), 174 | input_cost_per_1k: 3.0, 175 | output_cost_per_1k: 15.0, 176 | cache_creation_cost_per_1k: 3.75, 177 | cache_read_cost_per_1k: 0.3, 178 | }; 179 | 180 | let cost = calculate_entry_cost(&entry, &pricing); 181 | // 1000/1000 * 3.0 + 500/1000 * 15.0 + 100/1000 * 3.75 + 50/1000 * 0.3 182 | // = 3.0 + 7.5 + 0.375 + 0.015 = 10.89 183 | assert!((cost - 10.89).abs() < 0.001); 184 | } 185 | 186 | #[test] 187 | fn test_format_remaining_time() { 188 | assert_eq!(format_remaining_time(0), "expired"); 189 | assert_eq!(format_remaining_time(-10), "expired"); 190 | assert_eq!(format_remaining_time(30), "30m"); 191 | assert_eq!(format_remaining_time(90), "1h 30m"); 192 | assert_eq!(format_remaining_time(125), "2h 5m"); 193 | } 194 | 195 | #[test] 196 | fn test_calculate_daily_total() { 197 | let now = Utc::now(); 198 | let entries = vec![ 199 | UsageEntry { 200 | timestamp: now, 201 | input_tokens: 1000, 202 | output_tokens: 500, 203 | cache_creation_tokens: 0, 204 | cache_read_tokens: 0, 205 | model: "claude-3-5-sonnet".to_string(), 206 | cost: None, 207 | session_id: "test1".to_string(), 208 | }, 209 | UsageEntry { 210 | timestamp: now - Duration::days(1), // Yesterday 211 | input_tokens: 1000, 212 | output_tokens: 500, 213 | cache_creation_tokens: 0, 214 | cache_read_tokens: 0, 215 | model: "claude-3-5-sonnet".to_string(), 216 | cost: None, 217 | session_id: "test2".to_string(), 218 | }, 219 | ]; 220 | 221 | let mut pricing_map = HashMap::new(); 222 | pricing_map.insert( 223 | "claude-3-5-sonnet".to_string(), 224 | ModelPricing { 225 | model_name: "claude-3-5-sonnet".to_string(), 226 | input_cost_per_1k: 3.0, 227 | output_cost_per_1k: 15.0, 228 | cache_creation_cost_per_1k: 0.0, 229 | cache_read_cost_per_1k: 0.0, 230 | }, 231 | ); 232 | 233 | let total = calculate_daily_total(&entries, &pricing_map); 234 | // Only today's entry: 1000/1000 * 3.0 + 500/1000 * 15.0 = 3.0 + 7.5 = 10.5 235 | assert!((total - 10.5).abs() < 0.001); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CCometixLine 2 | 3 | [English](README.md) | [中文](README.zh.md) 4 | 5 | A high-performance Claude Code statusline tool written in Rust with Git integration and real-time usage tracking. 6 | 7 | ![Language:Rust](https://img.shields.io/static/v1?label=Language&message=Rust&color=orange&style=flat-square) 8 | ![License:MIT](https://img.shields.io/static/v1?label=License&message=MIT&color=blue&style=flat-square) 9 | 10 | ## Screenshots 11 | 12 | ![CCometixLine](assets/img1.png) 13 | 14 | The statusline shows: Model | Directory | Git Branch Status | Usage | Cost Statistics | Burn Rate 15 | 16 | ## Features 17 | 18 | - **High performance** with Rust native speed 19 | - **Git integration** with branch, status, and tracking info 20 | - **Model display** with simplified Claude model names 21 | - **Usage tracking** based on transcript analysis 22 | - **Cost tracking** with session, daily, and billing block statistics 23 | - **Burn rate monitoring** for real-time consumption patterns 24 | - **Directory display** showing current workspace 25 | - **Minimal design** using Nerd Font icons 26 | - **Simple configuration** via command line options 27 | - **Environment variable control** for feature customization 28 | 29 | ## Installation 30 | 31 | Download from [Releases](https://github.com/Haleclipse/CCometixLine/releases): 32 | 33 | ### Linux 34 | 35 | #### Option 1: Dynamic Binary (Recommended) 36 | ```bash 37 | mkdir -p ~/.claude/ccline 38 | wget https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-linux-x64.tar.gz 39 | tar -xzf ccline-linux-x64.tar.gz 40 | cp ccline ~/.claude/ccline/ 41 | chmod +x ~/.claude/ccline/ccline 42 | ``` 43 | *Requires: Ubuntu 22.04+, CentOS 9+, Debian 11+, RHEL 9+ (glibc 2.35+)* 44 | 45 | #### Option 2: Static Binary (Universal Compatibility) 46 | ```bash 47 | mkdir -p ~/.claude/ccline 48 | wget https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-linux-x64-static.tar.gz 49 | tar -xzf ccline-linux-x64-static.tar.gz 50 | cp ccline ~/.claude/ccline/ 51 | chmod +x ~/.claude/ccline/ccline 52 | ``` 53 | *Works on any Linux distribution (static, no dependencies)* 54 | 55 | ### macOS (Intel) 56 | 57 | ```bash 58 | mkdir -p ~/.claude/ccline 59 | wget https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-macos-x64.tar.gz 60 | tar -xzf ccline-macos-x64.tar.gz 61 | cp ccline ~/.claude/ccline/ 62 | chmod +x ~/.claude/ccline/ccline 63 | ``` 64 | 65 | ### macOS (Apple Silicon) 66 | 67 | ```bash 68 | mkdir -p ~/.claude/ccline 69 | wget https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-macos-arm64.tar.gz 70 | tar -xzf ccline-macos-arm64.tar.gz 71 | cp ccline ~/.claude/ccline/ 72 | chmod +x ~/.claude/ccline/ccline 73 | ``` 74 | 75 | ### Windows 76 | 77 | ```powershell 78 | # Create directory and download 79 | New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.claude\ccline" 80 | Invoke-WebRequest -Uri "https://github.com/Haleclipse/CCometixLine/releases/latest/download/ccline-windows-x64.zip" -OutFile "ccline-windows-x64.zip" 81 | Expand-Archive -Path "ccline-windows-x64.zip" -DestinationPath "." 82 | Move-Item "ccline.exe" "$env:USERPROFILE\.claude\ccline\" 83 | ``` 84 | 85 | ### Claude Code Configuration 86 | 87 | Add to your Claude Code `settings.json`: 88 | 89 | **Linux/macOS:** 90 | ```json 91 | { 92 | "statusLine": { 93 | "type": "command", 94 | "command": "~/.claude/ccline/ccline", 95 | "padding": 0 96 | } 97 | } 98 | ``` 99 | 100 | **Windows:** 101 | ```json 102 | { 103 | "statusLine": { 104 | "type": "command", 105 | "command": "%USERPROFILE%\\.claude\\ccline\\ccline.exe", 106 | "padding": 0 107 | } 108 | } 109 | ``` 110 | 111 | ### Build from Source 112 | 113 | ```bash 114 | git clone https://github.com/Haleclipse/CCometixLine.git 115 | cd CCometixLine 116 | cargo build --release 117 | 118 | # Linux/macOS 119 | mkdir -p ~/.claude/ccline 120 | cp target/release/ccometixline ~/.claude/ccline/ccline 121 | chmod +x ~/.claude/ccline/ccline 122 | 123 | # Windows (PowerShell) 124 | New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.claude\ccline" 125 | copy target\release\ccometixline.exe "$env:USERPROFILE\.claude\ccline\ccline.exe" 126 | ``` 127 | 128 | ## Usage 129 | 130 | ```bash 131 | # Basic usage (displays all enabled segments) 132 | ccline 133 | 134 | # Show help 135 | ccline --help 136 | 137 | # Print default configuration 138 | ccline --print-config 139 | 140 | # TUI configuration mode (planned) 141 | ccline --configure 142 | 143 | # Billing block management 144 | ccline --set-block-start