claude-dashboard 0.2.2

A terminal TUI dashboard that renders Claude Code usage reports with token stats, project breakdowns, and language distribution.
Documentation
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

// --- Session JSONL types ---

#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum SessionEvent {
    #[serde(rename = "user")]
    User {
        #[serde(default)]
        uuid: String,
        #[serde(default)]
        timestamp: Option<String>,
        #[serde(default)]
        message: Option<serde_json::Value>,
        #[serde(default)]
        session_id: Option<String>,
    },
    #[serde(rename = "assistant")]
    Assistant {
        #[serde(default)]
        uuid: String,
        #[serde(default)]
        timestamp: Option<String>,
        #[serde(default)]
        message: Option<AssistantMessage>,
        #[serde(default)]
        session_id: Option<String>,
    },
    #[serde(other)]
    Unknown,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct AssistantMessage {
    #[serde(default)]
    pub model: String,
    #[serde(default)]
    pub usage: Option<TokenUsage>,
    #[serde(default)]
    pub content: Option<Vec<serde_json::Value>>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct TokenUsage {
    #[serde(default)]
    pub input_tokens: u64,
    #[serde(default)]
    pub output_tokens: u64,
    #[serde(default)]
    pub cache_read_input_tokens: u64,
    #[serde(default)]
    pub cache_creation_input_tokens: u64,
    #[serde(default)]
    pub server_tool_use: Option<ServerToolUse>,
}

impl TokenUsage {
    pub fn total_tokens(&self) -> u64 {
        self.input_tokens + self.output_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ServerToolUse {
    #[serde(default)]
    pub web_search_requests: u64,
    #[serde(default)]
    pub web_fetch_requests: u64,
}

// --- Project directory info ---

#[derive(Debug, Clone)]
pub struct ProjectDir {
    pub display_name: String,
    pub full_path: String,
    pub session_files: Vec<PathBuf>,
    /// True if full_path came from history.jsonl or was decoded reliably.
    /// False when the encoded name suffered non-ASCII loss (e.g. Chinese chars
    /// mapped to dashes) and the real path is unrecoverable.
    pub path_resolved: bool,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_deserialize_assistant_with_tool_use() {
        let json = r#"{"type":"assistant","uuid":"abc","timestamp":"2026-04-15T10:30:00Z","message":{"model":"opus","usage":{"input_tokens":100,"output_tokens":50},"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}},{"type":"text","text":"done"},{"type":"tool_use","name":"Read","input":{"file":"a.txt"}}]}}"#;
        let event: SessionEvent = serde_json::from_str(json).unwrap();
        match event {
            SessionEvent::Assistant { message, .. } => {
                let msg = message.unwrap();
                let tool_calls = msg.content.as_ref()
                    .map(|blocks| blocks.iter().filter(|b| {
                        b.get("type").and_then(|t| t.as_str()) == Some("tool_use")
                    }).count())
                    .unwrap_or(0);
                assert_eq!(tool_calls, 2, "Should find 2 tool_use blocks in content");
            }
            _ => panic!("Expected Assistant event"),
        }
    }

    #[test]
    fn test_deserialize_assistant_without_content() {
        let json = r#"{"type":"assistant","uuid":"abc","timestamp":"2026-04-15T10:30:00Z","message":{"model":"opus","usage":{"input_tokens":100,"output_tokens":50}}}"#;
        let event: SessionEvent = serde_json::from_str(json).unwrap();
        match event {
            SessionEvent::Assistant { message, .. } => {
                let msg = message.unwrap();
                let tool_calls = msg.content.as_ref()
                    .map(|blocks| blocks.iter().filter(|b| {
                        b.get("type").and_then(|t| t.as_str()) == Some("tool_use")
                    }).count())
                    .unwrap_or(0);
                assert_eq!(tool_calls, 0, "No content means no tool calls");
            }
            _ => panic!("Expected Assistant event"),
        }
    }

    #[test]
    fn test_token_usage_total_tokens() {
        let tu = TokenUsage {
            input_tokens: 100,
            output_tokens: 50,
            cache_read_input_tokens: 25,
            cache_creation_input_tokens: 25,
            server_tool_use: None,
        };
        assert_eq!(tu.total_tokens(), 200);
    }

    #[test]
    fn test_deserialize_assistant_event() {
        let json = r#"{"type":"assistant","uuid":"abc","timestamp":"2026-04-15T10:30:00Z","message":{"model":"opus","usage":{"input_tokens":100,"output_tokens":50}}}"#;
        let event: SessionEvent = serde_json::from_str(json).unwrap();
        match event {
            SessionEvent::Assistant { timestamp, message, .. } => {
                assert_eq!(timestamp.unwrap(), "2026-04-15T10:30:00Z");
                let msg = message.unwrap();
                assert_eq!(msg.model, "opus");
                let usage = msg.usage.unwrap();
                assert_eq!(usage.input_tokens, 100);
                assert_eq!(usage.output_tokens, 50);
            }
            _ => panic!("Expected Assistant event"),
        }
    }

    #[test]
    fn test_deserialize_user_event() {
        let json = r#"{"type":"user","uuid":"xyz","timestamp":"2026-04-15T09:00:00Z","message":{"role":"user","content":"hello"}}"#;
        let event: SessionEvent = serde_json::from_str(json).unwrap();
        match event {
            SessionEvent::User { timestamp, .. } => {
                assert_eq!(timestamp.unwrap(), "2026-04-15T09:00:00Z");
            }
            _ => panic!("Expected User event"),
        }
    }

    #[test]
    fn test_deserialize_unknown_event_type() {
        // Unknown types map to SessionEvent::Unknown
        let json = r#"{"type":"some-new-type","data":"foo"}"#;
        let event: SessionEvent = serde_json::from_str(json).unwrap();
        assert!(matches!(event, SessionEvent::Unknown));
    }

    #[test]
    fn test_deserialize_token_usage_with_server_tool_use() {
        let json = r#"{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":25,"cache_creation_input_tokens":10,"server_tool_use":{"web_search_requests":3,"web_fetch_requests":2}}"#;
        let usage: TokenUsage = serde_json::from_str(json).unwrap();
        assert_eq!(usage.input_tokens, 100);
        let stu = usage.server_tool_use.unwrap();
        assert_eq!(stu.web_search_requests, 3);
        assert_eq!(stu.web_fetch_requests, 2);
    }

    #[test]
    fn test_deserialize_token_usage_without_server_tool_use() {
        let json = r#"{"input_tokens":100,"output_tokens":50}"#;
        let usage: TokenUsage = serde_json::from_str(json).unwrap();
        assert_eq!(usage.input_tokens, 100);
        assert!(usage.server_tool_use.is_none());
    }

    #[test]
    fn test_tool_use_name_extraction() {
        let json = r#"{"type":"assistant","uuid":"x","timestamp":"2026-04-15T10:30:00Z","message":{"model":"opus","usage":{"input_tokens":10,"output_tokens":5},"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}},{"type":"tool_use","name":"Read","input":{"file":"a.txt"}},{"type":"tool_use","name":"Bash","input":{"command":"pwd"}},{"type":"text","text":"output"}]}}"#;
        let event: SessionEvent = serde_json::from_str(json).unwrap();
        match event {
            SessionEvent::Assistant { message, .. } => {
                let msg = message.unwrap();
                let blocks = msg.content.unwrap();
                let mut tool_names: Vec<String> = blocks.iter()
                    .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
                    .filter_map(|b| b.get("name").and_then(|n| n.as_str()))
                    .map(|s| s.to_string())
                    .collect();
                tool_names.sort();
                assert_eq!(tool_names, vec!["Bash", "Bash", "Read"]);
            }
            _ => panic!("Expected Assistant event"),
        }
    }

    #[test]
    fn test_parse_real_session_file_from_disk() {
        let claude_dir = crate::utils::find_claude_dir(None);
        if claude_dir.is_none() {
            eprintln!("Skipping: no .claude directory found");
            return;
        }
        let projects_dir = claude_dir.unwrap().join("projects");
        if !projects_dir.exists() {
            eprintln!("Skipping: projects dir not found");
            return;
        }

        use std::fs;
        use std::io::{BufRead, BufReader};

        let mut total_events = 0u64;
        let mut assistant_events = 0u64;
        let mut total_tokens = 0u64;
        let mut total_tool_calls = 0u64;
        let mut model_counts: std::collections::HashMap<String, u64> = std::collections::HashMap::new();

        if let Ok(proj_entries) = fs::read_dir(&projects_dir) {
            for proj_entry in proj_entries {
                if let Ok(proj) = proj_entry {
                    let proj_path = proj.path();
                    if !proj_path.is_dir() { continue; }
                    if let Ok(session_entries) = fs::read_dir(&proj_path) {
                        for session_entry in session_entries {
                            if let Ok(sess) = session_entry {
                                let sess_path = sess.path();
                                if sess_path.extension().map(|e| e == "jsonl").unwrap_or(false) {
                                    if let Ok(file) = fs::File::open(&sess_path) {
                                        let reader = BufReader::new(file);
                                        for line in reader.lines() {
                                            if let Ok(line) = line {
                                                if line.trim().is_empty() { continue; }
                                                total_events += 1;
                                                if let Ok(SessionEvent::Assistant { message, .. }) =
                                                    serde_json::from_str::<SessionEvent>(&line)
                                                {
                                                    assistant_events += 1;
                                                    if let Some(msg) = message {
                                                        if let Some(usage) = msg.usage {
                                                            let t = usage.total_tokens();
                                                            total_tokens += t;
                                                            *model_counts.entry(msg.model).or_insert(0) += t;
                                                        }
                                                        let tc = msg.content.as_ref()
                                                            .map(|blocks| blocks.iter().filter(|b| {
                                                                b.get("type").and_then(|t| t.as_str()) == Some("tool_use")
                                                            }).count() as u64)
                                                            .unwrap_or(0);
                                                        total_tool_calls += tc;
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

        eprintln!("Total lines: {}, Assistant: {}, Tokens: {}M, Tool calls: {}",
            total_events, assistant_events, total_tokens as f64 / 1e6, total_tool_calls);
        assert!(total_events > 0);
        assert!(assistant_events > 0);
    }
}