diff --git a/README.md b/README.md index 925fee26..71d57897 100644 --- a/README.md +++ b/README.md @@ -65,17 +65,19 @@ Run one command to instantly review all of your CLI coding agent usage. Upload Run `splitrail web` and open `http://127.0.0.1:8765`. Use `splitrail web --port 9000` to choose another port. The server listens only on `127.0.0.1` and accepts that IP in the Host header. It reads local usage through the existing analyzers without uploading it. Static assets are embedded in the binary; no frontend build or Node installation is needed to run it. -The overview shows estimated cost, total tokens, session count, and cache share, with comparisons against the previous period. Filter by today, 7, 30, or 90 days, or all history, and select tools, models, or projects. Selections within one field combine; selections across fields narrow the result. Linked worktrees, repository subdirectories, and paths connected by an analyzer's project hash are grouped into one project. Hover a project to see its merged paths. +The overview shows estimated cost, total tokens, replies (model responses), session count, and cache share, with comparisons against the previous period. Filter by today, 7, 30, or 90 days, or all history, and select tools, models, or projects. Selections within one field combine; selections across fields narrow the result. Linked worktrees, repository subdirectories, and paths connected by an analyzer's project hash are grouped into one project. Hover a project to see its merged paths. -Switch between cost and tokens, and group the stacked trend by model, tool, or project. The chart supports hourly, daily, weekly, and monthly intervals; click a daily bar to inspect that day by hour. Focus the chart and use the arrow keys to read values, or Enter to drill into a day. Model, tool, and project rankings also filter on click. The five entities with the highest cost in the selected period keep their colors while filters change; the rest are grouped as Other. +Switch between cost, tokens, and replies, and group the stacked trend by model, tool, or project. The chart supports hourly, daily, weekly, and monthly intervals; click a daily bar to inspect that day by hour. Focus the chart and use the arrow keys to read values, or Enter to drill into a day. Model, tool, and project rankings also filter on click. The five entities with the highest cost in the selected period keep their colors while filters change; the rest are grouped as Other. Usage snapshots are saved in the system cache directory under `splitrail/web-usage-v1.json`. On restart, the page immediately shows the saved snapshot while a background scan refreshes it, then updates automatically. The first run without a valid cache waits for a scan. Click Refresh to scan again. Concurrent refreshes share one scan, cached pages remain available during scanning, and a failed refresh preserves the previous snapshot. Cache files with a different application version or timezone are ignored. Dates and hours use the machine's local time. Total tokens match the TUI: input + output + cached; reasoning tokens are not added again. Costs use public API prices and may differ from subscription spending. Frontend aggregation tests run with `node --test src/web/data.test.mjs`. -The Sessions tab provides search, sorting by recency/cost/tokens/duration, and a paginated list. Select a session to see its whole-session totals, reply and tool-call counts, token mix, model usage, and hourly or daily timeline. Shared filters select sessions; the detail panel shows the entire selected session. +Model aliases configured in `~/.splitrail.toml` also determine dashboard names and grouping, including filters, charts, rankings, and CSV exports. Session details show the original names beneath mapped names. Unmapped models keep their original names. Restart `splitrail web` after changing aliases; saved snapshots retain the original names and use the current aliases on load. -The Breakdown tab groups the filtered usage by period, project, model, or tool. Export CSV downloads the same groups, columns, ordering, and total row with exact numeric values. Shares are fractions between 0 and 1; text labels are escaped for spreadsheet use. View and filter selections are kept in the URL hash for bookmarks. +The Sessions tab provides search, sorting by recency/cost/tokens/replies/active time, and a paginated list. A compact summary shows total active time, replies, cost and tokens per reply, cache share, and project count. Above the list, the session shape card buckets the filtered sessions by reply count, active time, or tool calls, with the median and p90 beside it; the work activity card shows recorded tool calls, file reads, file operations, and shell commands. Active time counts gaps under five minutes between messages, so long idle periods do not inflate session duration. Select a session to see its whole-session totals, reply and tool-call counts, file reads and edits, token mix, per-model replies, tokens, cache share and cost, and its hourly or daily timeline. Shared filters select sessions; the detail panel shows the entire selected session. File and command counts are tool-operation counts, not unique file counts, and are available only where the source analyzer records them. + +The Breakdown tab groups the filtered usage by period, project, model, or tool, and includes tool calls, file reads, file operations, and shell commands. Export CSV downloads the same groups, columns, ordering, and total row with exact numeric values. Shares are fractions between 0 and 1; text labels are escaped for spreadsheet use. View and filter selections are kept in the URL hash for bookmarks. English is the default language; choose Simplified or Traditional Chinese in the header. The preference is saved locally. The dashboard follows the system's light/dark appearance and adapts to phone widths, with horizontally scrollable charts and tables. Overview also includes a weekday/hour activity heatmap and the largest sessions. diff --git a/src/analyzers/codex_cli.rs b/src/analyzers/codex_cli.rs index 70ac236e..66fa762b 100644 --- a/src/analyzers/codex_cli.rs +++ b/src/analyzers/codex_cli.rs @@ -91,25 +91,38 @@ pub(crate) fn data_dirs_with_roots( .unwrap_or_default() } +/// Session files are plain JSONL, or `*.jsonl.zst` once Codex compresses them. +fn session_file_name(path: &Path) -> Option<&str> { + let name = path.file_name()?.to_str()?; + Some(name.strip_suffix(".zst").unwrap_or(name)) +} + +pub(crate) fn is_codex_session_file(path: &Path) -> bool { + session_file_name(path).is_some_and(|name| name.ends_with(".jsonl")) +} + pub(crate) fn canonical_session_path(file_path: &Path) -> PathBuf { + // Drop a compression suffix so a session keeps the same identity (and hash) + // after Codex compresses it in place. + let Some(file_name) = session_file_name(file_path) else { + return file_path.to_path_buf(); + }; + let uncompressed_path = file_path.with_file_name(file_name); let Some(archive_dir) = file_path .ancestors() .find(|path| path.file_name() == Some(OsStr::new("archived_sessions"))) else { - return file_path.to_path_buf(); + return uncompressed_path; }; let Some(codex_dir) = archive_dir.parent() else { - return file_path.to_path_buf(); - }; - let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else { - return file_path.to_path_buf(); + return uncompressed_path; }; let Some(date) = file_name .strip_prefix("rollout-") .and_then(|rest| rest.get(..10)) .and_then(|date| chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()) else { - return file_path.to_path_buf(); + return uncompressed_path; }; codex_dir @@ -129,13 +142,7 @@ pub(crate) fn discover_data_sources_in( .filter(|dir| dir.is_dir()) .flat_map(|dir| WalkDir::new(dir).into_iter()) .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry.file_type().is_file() - && entry - .path() - .extension() - .is_some_and(|extension| extension == "jsonl") - }) + .filter(|entry| entry.file_type().is_file() && is_codex_session_file(entry.path())) .filter(|entry| seen_sessions.insert(canonical_session_path(entry.path()))) .map(|entry| DataSource { path: entry.into_path(), @@ -145,9 +152,7 @@ pub(crate) fn discover_data_sources_in( pub(crate) fn is_valid_data_path_in(path: &Path, data_dirs: &[PathBuf]) -> bool { path.is_file() - && path - .extension() - .is_some_and(|extension| extension == "jsonl") + && is_codex_session_file(path) && data_dirs.iter().any(|data_dir| path.starts_with(data_dir)) } @@ -160,7 +165,13 @@ impl Analyzer for CodexCliAnalyzer { fn get_data_glob_patterns(&self) -> Vec { Self::data_dirs() .into_iter() - .map(|data_dir| format!("{}/**/*.jsonl", data_dir.to_string_lossy())) + .flat_map(|data_dir| { + let base = data_dir.to_string_lossy().into_owned(); + [ + format!("{base}/**/*.jsonl"), + format!("{base}/**/*.jsonl.zst"), + ] + }) .collect() } @@ -174,9 +185,7 @@ impl Analyzer for CodexCliAnalyzer { .filter(|d| d.is_dir()) .flat_map(|dir| WalkDir::new(dir).into_iter()) .filter_map(|e| e.ok()) - .any(|e| { - e.file_type().is_file() && e.path().extension().is_some_and(|ext| ext == "jsonl") - }) + .any(|e| e.file_type().is_file() && is_codex_session_file(e.path())) } fn parse_source(&self, source: &DataSource) -> Result> { @@ -314,6 +323,25 @@ fn is_noise_title_candidate(text: &str) -> bool { || trimmed.starts_with("# AGENTS.md instructions for") } +fn record_tool_call(name: &str, stats: &mut Stats) { + stats.tool_calls = stats.tool_calls.saturating_add(1); + match name { + "exec_command" | "exec" | "shell" | "bash" | "local_shell" | "run_terminal_command" => { + stats.terminal_commands = stats.terminal_commands.saturating_add(1); + } + "apply_patch" | "edit" | "edit_file" => { + stats.files_edited = stats.files_edited.saturating_add(1); + } + "read" | "read_file" | "view_file" => { + stats.files_read = stats.files_read.saturating_add(1); + } + "write" | "write_file" | "create_file" => { + stats.files_added = stats.files_added.saturating_add(1); + } + _ => {} + } +} + /// Returns (messages, detected_session_model_name) pub(crate) fn parse_codex_cli_jsonl_file( file_path: &Path, @@ -325,17 +353,34 @@ pub(crate) fn parse_codex_cli_jsonl_file( .to_string_lossy() .into_owned(); - let mut file = File::open(file_path)?; - - // Read entire file at once to avoid per-line allocations + // Read entire file at once to avoid per-line allocations. Codex compresses + // closed sessions in place to `*.jsonl.zst`, which is transparently decoded + // here so both forms parse identically. The local data is never rewritten. let mut buffer = Vec::new(); - file.read_to_end(&mut buffer)?; + if file_path + .extension() + .is_some_and(|extension| extension == "zst") + { + let mut decoder = zstd::stream::read::Decoder::new(File::open(file_path)?)?; + // A torn or truncated frame still decodes its complete blocks, so keep + // those messages instead of dropping the whole session. + if let Err(error) = decoder.read_to_end(&mut buffer) { + warn_once(format!( + "WARNING: Codex session {file_path_str} has a truncated compressed tail; parsing {} decoded bytes: {error}", + buffer.len() + )); + } + } else { + File::open(file_path)?.read_to_end(&mut buffer)?; + } let mut session_model: Option = None; let mut previous_total_usage: Option = None; let mut saw_token_usage = false; let mut _turn_context: Option = None; let mut current_tool_call_ids: HashSet = HashSet::with_capacity(20); + let mut current_tool_stats = Stats::default(); + let mut pending_tool_entries = Vec::new(); let mut session_name: Option = None; let mut fallback_session_name: Option = None; let mut project_path: Option = None; @@ -409,16 +454,41 @@ pub(crate) fn parse_codex_cli_jsonl_file( "response_item" => { if let simd_json::OwnedValue::Object(map) = &wrapper.payload && let Some(simd_json::OwnedValue::String(item_type)) = map.get("type") - && item_type == "function_call" + && matches!(item_type.as_str(), "function_call" | "custom_tool_call") { - if let Some(simd_json::OwnedValue::String(call_id)) = map.get("call_id") { - current_tool_call_ids.insert(call_id.clone()); - } else { - current_tool_call_ids.insert(format!( + let call_id = match map.get("call_id") { + Some(simd_json::OwnedValue::String(call_id)) => call_id.clone(), + _ => format!( "{}_{}", wrapper.timestamp.to_rfc3339(), current_tool_call_ids.len() - )); + ), + }; + if current_tool_call_ids.insert(call_id.clone()) + && let Some(simd_json::OwnedValue::String(name)) = map.get("name") + { + let mut stats = Stats::default(); + record_tool_call(name, &mut stats); + record_tool_call(name, &mut current_tool_stats); + pending_tool_entries.push(ConversationMessage { + application: Application::CodexCli, + date: wrapper.timestamp, + project_hash: project_hash.clone(), + project_path: project_path.clone(), + conversation_hash: hash_text(&session_path_str), + local_hash: None, + global_hash: hash_text(&format!("{session_path_str}_tool_{call_id}")), + model: Some(session_model.as_ref().map_or_else( + || get_fallback_model().to_string(), + |model| model.name.clone(), + )), + stats, + role: MessageRole::Assistant, + uuid: None, + session_name: session_name + .clone() + .or_else(|| fallback_session_name.clone()), + }); } continue; } @@ -598,8 +668,14 @@ pub(crate) fn parse_codex_cli_jsonl_file( &model_state.name, wrapper.timestamp, ); - stats.tool_calls = current_tool_call_ids.len() as u32; + stats.tool_calls = current_tool_stats.tool_calls; + stats.files_read = current_tool_stats.files_read; + stats.files_edited = current_tool_stats.files_edited; + stats.files_added = current_tool_stats.files_added; + stats.terminal_commands = current_tool_stats.terminal_commands; current_tool_call_ids.clear(); + current_tool_stats = Stats::default(); + pending_tool_entries.clear(); entries.push(ConversationMessage { application: Application::CodexCli, @@ -634,6 +710,8 @@ pub(crate) fn parse_codex_cli_jsonl_file( } } + entries.extend(pending_tool_entries); + // Return both messages and the detected session model name let detected_model = session_model.map(|m| m.name); Ok((entries, detected_model)) diff --git a/src/analyzers/tests/codex_cli.rs b/src/analyzers/tests/codex_cli.rs index 5c8ea1cd..034ea6a6 100644 --- a/src/analyzers/tests/codex_cli.rs +++ b/src/analyzers/tests/codex_cli.rs @@ -9,7 +9,16 @@ const SESSION_FILE_NAME: &str = "rollout-2026-07-22T12-34-56-243232f1-a7ab-44e6-b2c3-045b673746ea.jsonl"; fn write_test_session(path: &std::path::Path) { - let content = concat!( + std::fs::write(path, test_session_content()).unwrap(); +} + +fn write_compressed_test_session(path: &std::path::Path) { + let compressed = zstd::stream::encode_all(test_session_content().as_bytes(), 1).unwrap(); + std::fs::write(path, compressed).unwrap(); +} + +fn test_session_content() -> String { + concat!( r#"{"timestamp":"2026-07-22T12:34:56.000Z","type":"session_meta","payload":{"id":"243232f1-a7ab-44e6-b2c3-045b673746ea","timestamp":"2026-07-22T12:34:56.000Z"}}"#, "\n", r#"{"timestamp":"2026-07-22T12:35:00.000Z","type":"turn_context","payload":{"model":"gpt-5-codex"}}"#, @@ -20,8 +29,8 @@ fn write_test_session(path: &std::path::Path) { "\n", r#"{"timestamp":"2026-07-22T12:35:02.000Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":120,"cached_input_tokens":20,"output_tokens":30,"reasoning_output_tokens":5,"total_tokens":150}}}}"#, "\n" - ); - std::fs::write(path, content).unwrap(); + ) + .to_string() } #[test] @@ -69,6 +78,19 @@ fn test_archived_session_uses_original_active_path_as_identity() { ); } +#[test] +fn test_unparseable_archived_session_ignores_compression_suffix() { + let home = std::path::PathBuf::from("/home/test"); + let archived = home + .join(".codex/archived_sessions") + .join("session.jsonl.zst"); + + assert_eq!( + canonical_session_path(&archived), + home.join(".codex/archived_sessions/session.jsonl") + ); +} + #[test] fn test_active_and_archived_copies_are_discovered_once() { let temp_dir = tempfile::tempdir().unwrap(); @@ -101,6 +123,120 @@ fn test_valid_codex_data_paths_must_be_inside_watched_directories() { let data_dirs = vec![sessions_dir]; assert!(is_valid_data_path_in(&session_path, &data_dirs)); assert!(!is_valid_data_path_in(&unrelated_path, &data_dirs)); + + let compressed_path = data_dirs[0].join("session.jsonl.zst"); + std::fs::write(&compressed_path, "").unwrap(); + assert!(is_valid_data_path_in(&compressed_path, &data_dirs)); + assert!(!is_valid_data_path_in( + &data_dirs[0].join("session.jsonl.gz"), + &data_dirs + )); +} + +#[test] +fn test_compressed_session_is_discovered_and_deduped_with_plain_copy() { + let temp_dir = tempfile::tempdir().unwrap(); + let active_dir = temp_dir.path().join(".codex/sessions/2026/07/22"); + std::fs::create_dir_all(&active_dir).unwrap(); + let plain_path = active_dir.join(SESSION_FILE_NAME); + let compressed_path = active_dir.join(format!("{SESSION_FILE_NAME}.zst")); + write_test_session(&plain_path); + write_compressed_test_session(&compressed_path); + std::fs::write(active_dir.join("session.jsonl.gz"), "").unwrap(); + + let sources = discover_data_sources_in(vec![temp_dir.path().join(".codex/sessions")]); + assert_eq!(sources.len(), 1); + assert!(sources[0].path == plain_path || sources[0].path == compressed_path); +} + +#[test] +fn test_compressed_session_parses_like_plain() { + let temp_dir = tempfile::tempdir().unwrap(); + let home = temp_dir.path(); + let active_dir = home + .join(".codex") + .join("sessions") + .join("2026") + .join("07") + .join("22"); + let archived_dir = home.join(".codex").join("archived_sessions"); + std::fs::create_dir_all(&active_dir).unwrap(); + std::fs::create_dir_all(&archived_dir).unwrap(); + let plain_path = active_dir.join(SESSION_FILE_NAME); + let compressed_path = archived_dir.join(format!("{SESSION_FILE_NAME}.zst")); + write_test_session(&plain_path); + write_compressed_test_session(&compressed_path); + + // Both files describe the same session, so the compressed copy must produce + // the same identities and token stats as the plain one. + assert_eq!( + canonical_session_path(&compressed_path), + canonical_session_path(&plain_path) + ); + let (plain_messages, plain_model) = parse_codex_cli_jsonl_file(&plain_path).unwrap(); + let (compressed_messages, compressed_model) = + parse_codex_cli_jsonl_file(&compressed_path).unwrap(); + + assert_eq!(plain_model, compressed_model); + assert_eq!(plain_messages.len(), compressed_messages.len()); + for (plain, compressed) in plain_messages.iter().zip(&compressed_messages) { + assert_eq!(plain.role, compressed.role); + assert_eq!(plain.conversation_hash, compressed.conversation_hash); + assert_eq!(plain.global_hash, compressed.global_hash); + assert_eq!(plain.stats.input_tokens, compressed.stats.input_tokens); + assert_eq!(plain.stats.output_tokens, compressed.stats.output_tokens); + assert_eq!(plain.stats.cached_tokens, compressed.stats.cached_tokens); + } +} + +#[test] +fn test_compressed_archived_session_matches_uncompressed_identity() { + let temp_dir = tempfile::tempdir().unwrap(); + let home = temp_dir.path(); + let archived_dir = home.join(".codex/archived_sessions"); + std::fs::create_dir_all(&archived_dir).unwrap(); + let archived_path = archived_dir.join(format!("{SESSION_FILE_NAME}.zst")); + write_compressed_test_session(&archived_path); + + assert_eq!( + canonical_session_path(&archived_path), + home.join(".codex/sessions/2026/07/22") + .join(SESSION_FILE_NAME) + ); + + let (messages, _) = parse_codex_cli_jsonl_file(&archived_path).unwrap(); + assert!(!messages.is_empty()); + let expected_hash = + crate::utils::hash_text(&canonical_session_path(&archived_path).to_string_lossy()); + assert_eq!(messages[0].conversation_hash, expected_hash); +} + +#[test] +fn test_truncated_compressed_session_keeps_complete_messages() { + let temp_dir = tempfile::tempdir().unwrap(); + let path = temp_dir.path().join(format!("{SESSION_FILE_NAME}.zst")); + let head = concat!( + r#"{"timestamp":"2026-07-22T12:34:56.000Z","type":"session_meta","payload":{"id":"243232f1-a7ab-44e6-b2c3-045b673746ea","timestamp":"2026-07-22T12:34:56.000Z"}}"#, + "\n", + r#"{"timestamp":"2026-07-22T12:35:00.000Z","type":"turn_context","payload":{"model":"gpt-5-codex"}}"#, + "\n", + r#"{"timestamp":"2026-07-22T12:35:01.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Test truncated frames"}]}}"#, + "\n", + ); + let tail = concat!( + r#"{"timestamp":"2026-07-22T12:35:02.000Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":120,"cached_input_tokens":20,"output_tokens":30,"reasoning_output_tokens":5,"total_tokens":150}}}}"#, + "\n", + ); + + let mut compressed = zstd::stream::encode_all(head.as_bytes(), 1).unwrap(); + let mut truncated = zstd::stream::encode_all(tail.as_bytes(), 1).unwrap(); + truncated.truncate(truncated.len() - 2); + compressed.extend(truncated); + std::fs::write(&path, compressed).unwrap(); + + let (messages, _) = parse_codex_cli_jsonl_file(&path).unwrap(); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0].role, crate::types::MessageRole::User)); } #[test] @@ -398,11 +534,129 @@ fn test_parse_codex_cli_counts_tool_calls() { .unwrap(); assert_eq!(assistant_msg.stats.tool_calls, 2); + assert_eq!(assistant_msg.stats.terminal_commands, 2); assert_eq!(assistant_msg.stats.input_tokens, 100); assert_eq!(assistant_msg.stats.output_tokens, 30); assert_eq!(assistant_msg.stats.reasoning_tokens, 5); } +#[test] +fn test_parse_codex_cli_maps_tool_calls_to_activity_stats() { + let mut temp_file = NamedTempFile::new().unwrap(); + + writeln!( + temp_file, + r#"{{"timestamp":"2026-10-17T12:00:00.000Z","type":"turn_context","payload":{{"model":"gpt-5-codex"}}}}"# + ) + .unwrap(); + writeln!( + temp_file, + r#"{{"timestamp":"2026-10-17T12:00:01.000Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"Inspect the repo"}}]}}}}"# + ) + .unwrap(); + writeln!( + temp_file, + r#"{{"timestamp":"2026-10-17T12:00:02.000Z","type":"response_item","payload":{{"type":"function_call","name":"exec_command","arguments":"{{}}","call_id":"call_exec"}}}}"# + ) + .unwrap(); + writeln!( + temp_file, + r#"{{"timestamp":"2026-10-17T12:00:03.000Z","type":"response_item","payload":{{"type":"custom_tool_call","name":"apply_patch","input":"*** Begin Patch","call_id":"call_patch"}}}}"# + ) + .unwrap(); + writeln!( + temp_file, + r#"{{"timestamp":"2026-10-17T12:00:04.000Z","type":"response_item","payload":{{"type":"function_call","name":"read_file","arguments":"{{}}","call_id":"call_read"}}}}"# + ) + .unwrap(); + writeln!( + temp_file, + r#"{{"timestamp":"2026-10-17T12:00:05.000Z","type":"response_item","payload":{{"type":"custom_tool_call","name":"write_file","input":"hello","call_id":"call_write"}}}}"# + ) + .unwrap(); + let before_usage_len = temp_file.as_file().metadata().unwrap().len(); + writeln!( + temp_file, + r#"{{"timestamp":"2026-10-17T12:00:06.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":120,"cached_input_tokens":20,"output_tokens":30,"reasoning_output_tokens":5,"total_tokens":155}}}}}}}}"# + ) + .unwrap(); + + let (messages, _model) = parse_codex_cli_jsonl_file(temp_file.path()).unwrap(); + let assistant_msg = messages + .iter() + .find(|msg| matches!(msg.role, crate::types::MessageRole::Assistant)) + .unwrap(); + + assert_eq!(assistant_msg.stats.tool_calls, 4); + assert_eq!(assistant_msg.stats.terminal_commands, 1); + assert_eq!(assistant_msg.stats.files_edited, 1); + assert_eq!(assistant_msg.stats.files_read, 1); + assert_eq!(assistant_msg.stats.files_added, 1); + + for (index, name) in ["exec_command", "read_file", "apply_patch", "write_file"] + .iter() + .enumerate() + { + writeln!(temp_file, r#"{{"timestamp":"2026-10-18T12:00:07.000Z","type":"response_item","payload":{{"type":"function_call","name":"{name}","arguments":"{{}}","call_id":"tail_{index}"}}}}"#).unwrap(); + } + writeln!(temp_file, r#"{{"timestamp":"2026-10-17T12:00:08.000Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"Continue"}}]}}}}"#).unwrap(); + let (messages, _) = parse_codex_cli_jsonl_file(temp_file.path()).unwrap(); + let usage = messages + .iter() + .find(|msg| msg.stats.input_tokens > 0) + .unwrap(); + assert_eq!(usage.stats.tool_calls, 4); + assert_eq!( + messages.iter().map(|msg| msg.stats.tool_calls).sum::(), + 8 + ); + let trailing: Vec<_> = messages + .iter() + .filter(|msg| msg.date.date_naive().to_string() == "2026-10-18") + .collect(); + assert_eq!(trailing.len(), 4); + assert_eq!( + trailing + .iter() + .map(|msg| msg.stats.terminal_commands) + .sum::(), + 1 + ); + assert_eq!( + trailing.iter().map(|msg| msg.stats.files_read).sum::(), + 1 + ); + assert_eq!( + trailing + .iter() + .map(|msg| msg.stats.files_edited) + .sum::(), + 1 + ); + assert_eq!( + trailing + .iter() + .map(|msg| msg.stats.files_added) + .sum::(), + 1 + ); + assert_eq!(usage.stats.input_tokens, assistant_msg.stats.input_tokens); + assert_eq!(usage.stats.output_tokens, assistant_msg.stats.output_tokens); + temp_file.as_file().set_len(before_usage_len).unwrap(); + let (messages, _) = parse_codex_cli_jsonl_file(temp_file.path()).unwrap(); + let activity: Vec<_> = messages + .iter() + .filter(|msg| msg.stats.tool_calls > 0) + .collect(); + assert_eq!(activity.len(), 4); + assert!( + activity + .iter() + .all(|msg| msg.stats.input_tokens == 0 && msg.stats.output_tokens == 0) + ); + assert_eq!(activity[0].date.to_rfc3339(), "2026-10-17T12:00:02+00:00"); +} + #[test] fn test_parse_codex_cli_missing_model() { let mut temp_file = NamedTempFile::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index 5efafc38..e46052f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -202,7 +202,7 @@ async fn main() { } } Some(Commands::Web(args)) => { - if let Err(e) = web::run(args.port).await { + if let Err(e) = web::run(args.port, config.aliases).await { eprintln!("Web server error: {e:#}"); std::process::exit(1); } diff --git a/src/web.rs b/src/web.rs index b9206c18..b90a1805 100644 --- a/src/web.rs +++ b/src/web.rs @@ -38,6 +38,14 @@ struct HourlyUsage { cost: f64, messages: u32, tool_calls: u32, + #[serde(default)] + files_read: u64, + #[serde(default)] + files_edited: u64, + #[serde(default)] + files_added: u64, + #[serde(default)] + terminal_commands: u64, } #[derive(Deserialize, Serialize)] @@ -49,6 +57,10 @@ struct SessionInfo { project: String, start: DateTime, end: DateTime, + #[serde(default)] + active_minutes: u32, + #[serde(skip)] + timestamps: Vec>, } /// A project assembled from several paths (worktrees, moves, subdirectories). @@ -67,6 +79,16 @@ struct UsageSnapshot { projects: Vec, } +fn active_minutes(timestamps: &mut [DateTime]) -> u32 { + timestamps.sort_unstable(); + let seconds: i64 = timestamps + .windows(2) + .map(|pair| (pair[1] - pair[0]).num_seconds()) + .filter(|seconds| *seconds > 0 && *seconds < 5 * 60) + .sum(); + u32::try_from((seconds.max(60) + 59) / 60).unwrap_or(u32::MAX) +} + fn usage_snapshot(stats: &MultiAnalyzerStats) -> UsageSnapshot { let messages = || { stats.analyzer_stats.iter().flat_map(|analyzer| { @@ -114,12 +136,15 @@ fn usage_snapshot(stats: &MultiAnalyzerStats) -> UsageSnapshot { project: project.to_string(), start: message.date, end: message.date, + active_minutes: 0, + timestamps: Vec::new(), }); if message.date < session.start { session.start = message.date; session.project = project.to_string(); } session.end = session.end.max(message.date); + session.timestamps.push(message.date); if session.name.is_none() { session.name.clone_from(&message.session_name); } @@ -162,6 +187,12 @@ fn usage_snapshot(stats: &MultiAnalyzerStats) -> UsageSnapshot { row.cost += message.stats.cost; row.messages = row.messages.saturating_add(1); row.tool_calls = row.tool_calls.saturating_add(message.stats.tool_calls); + row.files_read = row.files_read.saturating_add(message.stats.files_read); + row.files_edited = row.files_edited.saturating_add(message.stats.files_edited); + row.files_added = row.files_added.saturating_add(message.stats.files_added); + row.terminal_commands = row + .terminal_commands + .saturating_add(message.stats.terminal_commands); } let rows: Vec = hours @@ -172,12 +203,21 @@ fn usage_snapshot(stats: &MultiAnalyzerStats) -> UsageSnapshot { || row.cached_tokens > 0 || row.reasoning_tokens > 0 || row.cost > 0.0 + || row.tool_calls > 0 + || row.files_read > 0 + || row.files_edited > 0 + || row.files_added > 0 + || row.terminal_commands > 0 }) .collect(); let used_sessions: HashSet<&str> = rows.iter().map(|row| row.session.as_str()).collect(); let mut sessions: Vec = sessions .into_values() .filter(|session| used_sessions.contains(session.id.as_str())) + .map(|mut session| { + session.active_minutes = active_minutes(&mut session.timestamps); + session + }) .collect(); sessions.sort_by(|left, right| { left.start @@ -217,6 +257,15 @@ struct UsageState { cached: parking_lot::RwLock>, scan: tokio::sync::Mutex<()>, cache_path: Option, + model_aliases: HashMap, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageResponse<'a> { + #[serde(flatten)] + snapshot: &'a UsageSnapshot, + model_aliases: &'a HashMap, } fn cache_timezone() -> String { @@ -364,7 +413,10 @@ async fn usage(State(state): State>, Query(query): Query Router { app_with_state(Arc::new(UsageState::default())) } -pub async fn run(port: u16) -> Result<()> { +pub async fn run(port: u16, model_aliases: HashMap) -> Result<()> { let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let listener = tokio::net::TcpListener::bind(address) .await .with_context(|| format!("Failed to listen on {address}"))?; println!("Splitrail web: http://{address}"); - let state = Arc::new( - dirs::cache_dir().map_or_else(UsageState::default, |directory| { - UsageState::persistent(directory.join("splitrail").join("web-usage-v1.json")) - }), - ); + let mut state = dirs::cache_dir().map_or_else(UsageState::default, |directory| { + UsageState::persistent(directory.join("splitrail").join("web-usage-v1.json")) + }); + state.model_aliases = model_aliases; + let state = Arc::new(state); if state.cached.read().is_some() { let refresh = state.clone(); tokio::spawn(async move { @@ -706,6 +758,41 @@ mod tests { assert!(!Arc::ptr_eq(&first, &retry)); } + #[tokio::test] + async fn serves_current_aliases_with_raw_cached_models() { + use crate::reqwest_simd_json::ResponseSimdJsonExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let mut snapshot = snapshot_with_input(10); + snapshot.rows[0].model = "group/auto-flash".into(); + let state = Arc::new(UsageState { + model_aliases: HashMap::from([("group/auto-flash".into(), "flash".into())]), + cached: parking_lot::RwLock::new(Some(CachedUsage { + snapshot: Arc::new(snapshot), + completed_at: Instant::now(), + from_disk: true, + })), + ..Default::default() + }); + let server = + tokio::spawn(async move { axum::serve(listener, app_with_state(state)).await }); + let response: simd_json::OwnedValue = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get(format!("http://{address}/api/usage")) + .send() + .await + .unwrap() + .simd_json() + .await + .unwrap(); + assert_eq!(response["modelAliases"]["group/auto-flash"], "flash"); + assert_eq!(response["rows"][0]["model"], "group/auto-flash"); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); + } + #[tokio::test] async fn rejects_remote_hosts_and_serves_embedded_modules() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -910,4 +997,76 @@ mod tests { assert_eq!(snapshot.rows[0].cost, 0.5); assert_eq!(snapshot.sessions.len(), 1); } + + #[test] + fn session_activity_counts_short_gaps_and_file_operations() { + let message_at = |minute| ConversationMessage { + application: Application::CodexCli, + date: Utc.with_ymd_and_hms(2026, 9, 22, 12, minute, 0).unwrap(), + project_hash: "hash".into(), + project_path: Some("/work/project".into()), + conversation_hash: "session".into(), + local_hash: None, + global_hash: format!("message-{minute}"), + model: Some("gpt-test".into()), + stats: Stats { + input_tokens: 10, + files_read: 2, + files_edited: 1, + files_added: 1, + terminal_commands: 1, + tool_calls: 1, + ..Default::default() + }, + role: MessageRole::Assistant, + uuid: None, + session_name: None, + }; + let stats = MultiAnalyzerStats { + analyzer_stats: vec![AgenticCodingToolStats { + daily_stats: BTreeMap::new(), + num_conversations: 1, + messages: vec![message_at(0), message_at(2), message_at(10)], + analyzer_name: "Codex CLI".into(), + }], + }; + + let snapshot = usage_snapshot(&stats); + assert_eq!(snapshot.rows.len(), 1); + assert_eq!(snapshot.rows[0].files_read, 6); + assert_eq!(snapshot.rows[0].files_edited, 3); + assert_eq!(snapshot.rows[0].files_added, 3); + assert_eq!(snapshot.rows[0].terminal_commands, 3); + assert_eq!(snapshot.sessions[0].active_minutes, 2); + + for activity in [ + Stats { + tool_calls: 1, + ..Default::default() + }, + Stats { + files_read: 1, + ..Default::default() + }, + Stats { + files_edited: 1, + ..Default::default() + }, + Stats { + files_added: 1, + ..Default::default() + }, + Stats { + terminal_commands: 1, + ..Default::default() + }, + ] { + let mut stats = stats.clone(); + stats.analyzer_stats[0].messages.truncate(1); + stats.analyzer_stats[0].messages[0].stats = activity; + let snapshot = usage_snapshot(&stats); + assert_eq!(snapshot.rows.len(), 1, "activity-only row must be retained"); + assert_eq!(snapshot.sessions.len(), 1); + } + } } diff --git a/src/web/app.js b/src/web/app.js index d2d548df..37b48278 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -1,7 +1,7 @@ import { OTHER, SESSION_PAGE_SIZE, rowTokens, localDateKey, shiftDate, periodKey, selectRange, inRange, matchesFilters, granularityOptions, bucketKeys, sumRows, groupRows, totalTokens, cacheShare, colorSlots, stackedSeries, - weekdayHourGrid, niceStep, topTenthShare, median, csv, + weekdayHourGrid, niceStep, topTenthShare, median, percentile, csv, SESSION_SHAPES, histogram, mapModelAliases, } from './data.mjs'; import { messages } from './locales.mjs'; @@ -9,8 +9,11 @@ const DIMENSIONS = ['tool', 'model', 'project']; const VIEWS = ['overview', 'sessions', 'breakdown']; const RANGES = ['today', '7', '30', '90', 'all']; const RANGE_LABELS = { today: 'today', 7: 'last7', 30: 'last30', 90: 'last90', all: 'all' }; -const METRICS = ['cost', 'tokens']; -const SORTS = ['recent', 'cost', 'tokens', 'duration']; +const METRICS = ['cost', 'tokens', 'requests']; +const SORTS = ['recent', 'cost', 'tokens', 'requests', 'duration']; +const SHAPES = ['messages', 'duration', 'toolCalls']; +// The shape toggle labels reuse existing keys: replies, active time, tool calls. +const SHAPE_LABELS = { messages: 'replies', duration: 'activeTime', toolCalls: 'toolCalls' }; const BREAKDOWNS = ['period', 'project', 'model', 'tool']; const GRANULARITIES = ['hour', 'day', 'week', 'month']; const PLOT_HEIGHT = 240; @@ -129,7 +132,7 @@ function compact(value) { function defaultState() { return { view: 'overview', range: '30', day: null, metric: 'cost', group: 'model', granularity: null, - sort: 'recent', q: '', session: null, dim: 'project', + sort: 'recent', q: '', session: null, dim: 'project', shape: 'messages', filters: { tool: new Set(), model: new Set(), project: new Set() }, }; } @@ -147,6 +150,7 @@ function readHash() { choose('granularity', GRANULARITIES); choose('sort', SORTS); choose('dim', BREAKDOWNS); + choose('shape', SHAPES); const day = params.get('day'); if (/^\d{4}-\d{2}-\d{2}$/.test(day ?? '') && localDateKey(noon(day)) === day) next.day = day; next.q = params.get('q') ?? ''; @@ -158,7 +162,7 @@ function readHash() { function writeHash() { const params = new URLSearchParams(); const defaults = defaultState(); - for (const key of ['view', 'range', 'day', 'metric', 'group', 'granularity', 'sort', 'q', 'session', 'dim']) { + for (const key of ['view', 'range', 'day', 'metric', 'group', 'granularity', 'sort', 'q', 'session', 'dim', 'shape']) { if (state[key] && state[key] !== defaults[key]) params.set(key, state[key]); } for (const key of DIMENSIONS) for (const value of state.filters[key]) params.append(key, value); @@ -215,7 +219,7 @@ document.addEventListener('visibilitychange', () => { function prepare(data) { const projects = new Map(data.projects.map(project => [project.id, project.paths])); return { - rows: data.rows, + rows: mapModelAliases(data.rows, data.modelAliases), sessions: new Map(data.sessions.map(session => [session.id, { ...session, start: new Date(session.start), end: new Date(session.end) }])), projects, labels: projectLabels([...projects.keys()], projects), @@ -252,6 +256,10 @@ function projectTitle(value) { return paths ? [value, ...paths.filter(path => path !== value)].join('\n') : label('project', value); } +function labelTitle(key, value) { + return key === 'project' ? projectTitle(value) : label(key, value); +} + function seriesColor(dimension, value) { const slot = slots[dimension].get(value); return slot === undefined ? 'var(--other)' : `var(--series-${slot})`; @@ -261,12 +269,15 @@ function sessionName(meta) { return meta.name?.trim() || t('untitled'); } -function sessionMinutes(meta) { - return Math.max(1, Math.round((meta.end - meta.start) / 60000)); +// Active time excludes idle gaps of five minutes or more between messages. +function activeMinutes(meta) { + return meta.activeMinutes || Math.max(1, Math.round((meta.end - meta.start) / 60000)); } -const amount = totals => (state.metric === 'cost' ? totals.cost : totalTokens(totals)); -const formatAmount = value => (state.metric === 'cost' ? fmt.cost(value) : fmt.compact(value)); +const amount = totals => (state.metric === 'cost' ? totals.cost + : state.metric === 'requests' ? totals.messages : totalTokens(totals)); +const formatAmount = value => (state.metric === 'cost' ? fmt.cost(value) + : state.metric === 'requests' ? fmt.int(value) : fmt.compact(value)); const formatAxis = value => (state.metric === 'cost' ? `$${fmt.compact(value)}` : fmt.compact(value)); function compute() { @@ -382,7 +393,7 @@ function renderTabs(view) { function renderFilters(view) { const chips = DIMENSIONS.flatMap(key => [...state.filters[key]].map(value => h('button', { type: 'button', class: 'chip', 'aria-label': t('removeFilter')(label(key, value)), 'data-focus': `chip:${key}:${value}`, - title: key === 'project' ? projectTitle(value) : null, onclick: () => toggleFilter(key, value), + title: labelTitle(key, value), onclick: () => toggleFilter(key, value), }, h('span', {}, `${t(key)}: ${label(key, value)}`), closeIcon()))); elements.filters.replaceChildren(...[ segmented(t('range'), RANGES.map(range => ({ value: range, label: t(RANGE_LABELS[range]), pressed: !state.day && state.range === range })), @@ -429,7 +440,7 @@ function filterMenu(key, view) { options.length ? h('div', { class: 'menu-list' }, options.map(([value, item]) => h('button', { type: 'button', class: 'menu-item', 'aria-pressed': String(selected.has(value)), 'data-focus': `option:${key}:${value}`, - title: key === 'project' ? projectTitle(value) : null, onclick: () => toggleFilter(key, value), + title: labelTitle(key, value), onclick: () => toggleFilter(key, value), }, h('span', { class: 'check', 'aria-hidden': 'true' }, selected.has(value) ? '✓' : ''), h('span', { class: 'ellipsis grow' }, label(key, value)), h('span', { class: 'num small muted' }, amount(item) ? formatAmount(amount(item)) : '—')))) @@ -488,7 +499,7 @@ function kpiSection(view) { const position = index.get(periodKey(row.date, row.hour, view.granularity)); if (position !== undefined) costs[position] += row.cost; } - const durations = [...totals.sessions].map(id => snapshot.sessions.get(id)).filter(Boolean).map(sessionMinutes); + const durations = [...totals.sessions].map(id => snapshot.sessions.get(id)).filter(Boolean).map(activeMinutes); const average = range.days === 1 ? (state.day ? t('wholeDay') : t('asOf')(fmt.clock(view.now))) : t('dailyAverage')(fmt.cost(totals.cost / range.days), fmt.int(totals.dates.size), fmt.int(range.days)); @@ -503,6 +514,8 @@ function kpiSection(view) { h('p', { class: 'small muted' }, average)), card(t('totalTokens'), fmt.compact(totalTokens(totals)), delta(totalTokens(totals), prev && totalTokens(prev), comparison), composition(totals, 'comp'), compositionLegend(totals)), + card(t('requests'), fmt.int(totals.messages), delta(totals.messages, prev?.messages, comparison), + h('p', { class: 'small muted' }, t('requestsSub')(fmt.compact(totals.messages ? totalTokens(totals) / totals.messages : 0)))), card(t('sessions'), fmt.int(totals.sessions.size), delta(totals.sessions.size, prev?.sessions.size, comparison), h('p', { class: 'small muted' }, durations.length ? t('sessionsSub')(fmt.duration(median(durations)), fmt.int(totals.messages)) : t('noSessions'))), card(t('cacheShare'), fmt.percent(cacheShare(totals)), prev ? points(cacheShare(totals), cacheShare(prev), comparison) : t('noComparison'), @@ -547,7 +560,7 @@ function stackBar(series, index, total, height, dimension) { function trendSection(view) { const series = stackedSeries(view.rows, view.keys, view.granularity, state.group, state.metric, slots[state.group]); - const title = t('trendTitle')(t('every')[view.granularity], state.metric); + const title = t('trendTitle')(t('every')[view.granularity], t(state.metric)); return h('section', { class: 'card trend' }, sectionHead('01', title, t('stackedBy')(t(state.group), rangeLabel(view.range)), h('span', { class: 'small muted' }, t('group')), @@ -588,7 +601,7 @@ function trendChart(series, view, title) { h('p', { class: 'tip-value num' }, formatAmount(totals[index])), h('div', { class: 'tip-rows' }, parts.map(item => h('div', { class: 'tip-row' }, h('span', { class: 'tip-key', style: `background:${seriesColor(state.group, item.id)}` }), - h('span', { class: 'ellipsis' }, label(state.group, item.id)), + h('span', { class: 'tip-label grow' }, label(state.group, item.id)), h('span', { class: 'num' }, formatAmount(item.values[index])))))); const position = (index + 0.5) / keys.length; tip.style.left = `${position * 100}%`; @@ -659,9 +672,10 @@ function rankCard(view, key, number) { sectionHead(number, t(key), null, h('span', { class: 'small muted' }, t('clickToFilter'))), groups.slice(0, 6).map(([value, totals]) => h('button', { type: 'button', class: 'rank-row', 'aria-pressed': String(state.filters[key].has(value)), 'data-focus': `rank:${key}:${value}`, - title: key === 'project' ? projectTitle(value) : label(key, value), onclick: () => toggleFilter(key, value), + title: labelTitle(key, value), onclick: () => toggleFilter(key, value), }, - h('span', { class: 'rank-name' }, key === state.group ? swatch(seriesColor(key, value)) : null, h('span', { class: 'ellipsis' }, label(key, value))), + h('span', { class: 'rank-name' }, key === state.group ? swatch(seriesColor(key, value)) : null, + h('span', { class: key === 'model' ? 'model-name' : 'ellipsis' }, label(key, value))), h('span', { class: 'rank-value num' }, formatAmount(amount(totals))), h('span', { class: 'rank-meter' }, h('span', { class: 'track', 'aria-hidden': 'true' }, h('span', { class: 'fill', style: `width:${max > 0 ? (amount(totals) / max) * 100 : 0}%` })), @@ -753,21 +767,25 @@ function heatSection(view) { function topSessionsSection(view) { const sessions = [...groupRows(view.rows, row => row.session)].filter(([id]) => snapshot.sessions.has(id)); const ranked = sessions.sort((a, b) => amount(b[1]) - amount(a[1])); - const { count, share } = topTenthShare(sessions.map(([, totals]) => totals.cost)); - const sort = state.metric === 'cost' ? 'cost' : 'tokens'; + const { count, share } = topTenthShare(sessions.map(([, totals]) => amount(totals))); + const sort = state.metric; return h('section', { class: 'card top' }, - sectionHead('06', state.metric === 'cost' ? t('topByCost') : t('topByTokens'), null, - sessions.length ? h('span', { class: 'small muted' }, t('concentration')(fmt.int(count), fmt.percent(share, 0))) : null), + sectionHead('06', t({ cost: 'topByCost', tokens: 'topByTokens', requests: 'topByRequests' }[state.metric]), null, + sessions.length ? h('span', { class: 'small muted' }, t('concentration')(fmt.int(count), fmt.percent(share, 0), t(state.metric).toLowerCase())) : null), ranked.slice(0, 5).map(([id, totals], index) => { const meta = snapshot.sessions.get(id); - return h('button', { type: 'button', class: 'top-row', 'data-focus': `top:${id}`, onclick: () => update({ view: 'sessions', sort, session: id, q: '' }) }, + return h('button', { + type: 'button', class: 'top-row', 'data-focus': `top:${id}`, + title: `${sessionName(meta)} · ${label('project', meta.project)} · ${meta.tool}`, + onclick: () => update({ view: 'sessions', sort, session: id, q: '' }), + }, h('span', { class: 'mono small muted' }, String(index + 1)), h('span', { class: 'ellipsis' }, h('span', { class: 'block ellipsis' }, sessionName(meta)), h('span', { class: 'block ellipsis small muted' }, `${label('project', meta.project)} · ${meta.tool} · ${fmt.md(localDateKey(meta.start))}`)), h('span', { class: 'right' }, h('span', { class: 'block num', style: 'font-weight:600' }, formatAmount(amount(totals))), - h('span', { class: 'block num small muted' }, fmt.duration(sessionMinutes(meta))))); + h('span', { class: 'block num small muted', title: t('activeTimeHelp') }, fmt.duration(activeMinutes(meta))))); }), sessions.length ? null : h('p', { class: 'menu-empty' }, t('noUsage')), h('button', { type: 'button', class: 'link more', onclick: () => update({ view: 'sessions', sort, session: null }) }, t('viewAllSessions'))); @@ -785,7 +803,8 @@ function sessionsView(view) { recent: (a, b) => b.meta.start - a.meta.start, cost: (a, b) => b.totals.cost - a.totals.cost, tokens: (a, b) => totalTokens(b.totals) - totalTokens(a.totals), - duration: (a, b) => sessionMinutes(b.meta) - sessionMinutes(a.meta), + requests: (a, b) => b.totals.messages - a.totals.messages, + duration: (a, b) => activeMinutes(b.meta) - activeMinutes(a.meta), }; const list = [...groupRows(view.rows, row => row.session)] .map(([id, totals]) => ({ id, totals, meta: snapshot.sessions.get(id) })) @@ -800,10 +819,10 @@ function sessionsView(view) { h('span', { class: 'sr-only' }, t('searchSessions')), svg('svg', { class: 'icon', viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': 'true' }, svg('circle', { cx: 7, cy: 7, r: 4.5 }), svg('path', { d: 'm10.5 10.5 3 3' })), h('input', { type: 'search', value: state.q, placeholder: t('searchSessions'), 'data-focus': 'session-search', oninput: event => update({ q: event.target.value }) })), - segmented(t('sort'), SORTS.map(sort => ({ value: sort, label: sort === 'duration' ? t('durationLabel') : t(sort), pressed: state.sort === sort })), sort => update({ sort })), + segmented(t('sort'), SORTS.map(sort => ({ value: sort, label: sort === 'duration' ? t('activeTime') : t(sort), pressed: state.sort === sort })), sort => update({ sort })), h('span', { class: 'small muted count-label' }, t('sessionCountIn')(fmt.int(list.length), rangeLabel(view.range)))), h('div', { class: 'sess-grid sess-head', 'aria-hidden': 'true' }, - h('span', {}, t('session')), h('span', { class: 'hide-sm' }, t('started')), h('span', { class: 'hide-sm' }, t('durationLabel')), + h('span', {}, t('session')), h('span', { class: 'hide-sm' }, t('started')), h('span', { class: 'hide-sm' }, t('activeTime')), h('span', { class: 'hide-sm right' }, t('replies')), h('span', { class: 'hide-sm' }, t('models')), h('span', { class: 'hide-sm right' }, t(state.metric === 'cost' ? 'tokens' : 'cost')), h('span', { class: 'hide-sm right' }, t('cached')), h('span', { class: 'right' }, t(state.metric))), @@ -814,7 +833,72 @@ function sessionsView(view) { list.length > shown.length ? h('button', { type: 'button', class: 'link', 'data-focus': 'load-more', onclick: () => { sessionLimit += SESSION_PAGE_SIZE; render(); } }, t('loadMore')) : null)); - return [h('div', { class: 'sess-layout' }, listCard, sessionDetail(selected))]; + const ids = new Set(list.map(item => item.id)); + const listTotals = sumRows(view.rows.filter(row => ids.has(row.session))); + return [ + list.length ? sessionKpis(list, listTotals) : null, + h('div', { class: 'sess-insights' }, shapeCard(list), activityCard(listTotals)), + h('div', { class: 'sess-layout' }, listCard, sessionDetail(selected)), + ]; +} + +function sessionKpis(list, totals) { + const active = list.reduce((sum, item) => sum + activeMinutes(item.meta), 0); + const projects = new Set(list.map(item => item.meta.project)).size; + const replies = totals.messages; + const stat = (name, value, title) => h('div', { class: 'session-kpi', title }, + h('span', { class: 'session-kpi-label' }, name), + h('span', { class: 'session-kpi-value num' }, value)); + return h('section', { class: 'card session-kpis', 'aria-label': t('sessionSummary') }, + stat(t('totalActive'), fmt.duration(active), t('activeTimeHelp')), + stat(t('replies'), fmt.int(replies)), + stat(t('costPerReply'), fmt.cost(replies ? totals.cost / replies : 0)), + stat(t('tokensPerReply'), fmt.compact(replies ? totalTokens(totals) / replies : 0)), + stat(t('cacheShare'), fmt.percent(cacheShare(totals))), + stat(t('projects'), fmt.int(projects))); +} + +// Session shape: how the filtered sessions spread over size bands, in the +// same spirit as Magpie's 会话形态 but limited to what local data records. +function shapeCard(list) { + const buckets = SESSION_SHAPES[state.shape]; + const value = item => (state.shape === 'messages' ? item.totals.messages + : state.shape === 'duration' ? activeMinutes(item.meta) : item.totals.toolCalls); + const values = list.map(value); + const counts = histogram(values, buckets); + const max = Math.max(0, ...counts); + const label = ([min, max_]) => (max_ === Infinity ? t('bandPlus')(fmt.int(min)) : min === max_ ? fmt.int(min) : t('bandRange')(fmt.int(min), fmt.int(max_))); + // Median and p90 follow the selected band so the header matches the bars. + const format = value => (state.shape === 'duration' ? fmt.duration(value) : fmt.int(value)); + const spread = values.length ? ` · ${t('sessionSpread')(format(median(values)), format(percentile(values, 0.9)))}` : ''; + return h('section', { class: 'card shape', 'aria-label': t('sessionShape') }, + sectionHead('01', t('sessionShape'), `${t('shapeSub')(fmt.int(list.length))}${spread}`, + segmented(t('sessionShape'), SHAPES.map(key => ({ value: key, label: t(SHAPE_LABELS[key]), pressed: state.shape === key })), shape => update({ shape }))), + h('div', { class: 'shape-bars' }, buckets.map((band, index) => h('div', { + class: 'shape-bar', title: t('shapeBand')(label(band), fmt.int(counts[index])), + }, + h('span', { class: 'shape-plot' }, + h('span', { class: 'num shape-count' }, counts[index] ? fmt.int(counts[index]) : ''), + h('span', { class: 'shape-fill', style: `height:${max ? (counts[index] / max) * 100 : 0}%` })), + h('span', { class: 'shape-label' }, label(band))))), + h('p', { class: 'hint' }, t('shapeNote'))); +} + +function activityCard(totals) { + const sessions = Math.max(1, totals.sessions.size); + const filesChanged = totals.filesEdited + totals.filesAdded; + const stat = (name, value, note) => h('div', { class: 'activity-stat' }, + h('p', { class: 'small muted' }, name), + h('p', { class: 'activity-value num' }, fmt.compact(value)), + h('p', { class: 'small muted' }, note)); + return h('section', { class: 'card activity', 'aria-label': t('workActivity') }, + sectionHead('02', t('workActivity'), t('activitySub')(fmt.int(totals.sessions.size))), + h('div', { class: 'activity-grid' }, + stat(t('toolCalls'), totals.toolCalls, t('perReply')(fmt.compact(totals.toolCalls / Math.max(1, totals.messages)))), + stat(t('filesRead'), totals.filesRead, t('perSession')(fmt.compact(totals.filesRead / sessions))), + stat(t('filesChanged'), filesChanged, t('fileChanges')(fmt.int(totals.filesAdded), fmt.int(totals.filesEdited))), + stat(t('shellCommands'), totals.terminalCommands, t('perSession')(fmt.compact(totals.terminalCommands / sessions)))), + h('p', { class: 'hint activity-note' }, t('activityNote'))); } function sessionRow(item, modelTokens, selected) { @@ -823,10 +907,13 @@ function sessionRow(item, modelTokens, selected) { const tokens = className => h('span', { class: `tokens-cell ${className}` }, h('span', { class: 'num' }, fmt.compact(totalTokens(totals))), composition(totals, 'mini-comp')); const cost = className => h('span', { class: `num right ${className}` }, fmt.cost(totals.cost)); + const replies = className => h('span', { class: `num right ${className}` }, fmt.int(totals.messages)); // The selected metric is the emphasized last column, the only figure left on phones. - const [secondary, primary] = state.metric === 'cost' ? [tokens, cost] : [cost, tokens]; + const [secondary, primary] = state.metric === 'cost' ? [tokens, cost] + : state.metric === 'requests' ? [cost, replies] : [cost, tokens]; return h('button', { type: 'button', class: 'sess-grid sess-row', 'aria-pressed': String(selected), 'data-focus': `session:${item.id}`, + title: `${sessionName(meta)} · ${label('project', meta.project)} · ${meta.tool}`, onclick: () => { update({ session: item.id }); // Below 1100px the details stack under the list. @@ -837,10 +924,11 @@ function sessionRow(item, modelTokens, selected) { h('span', { class: 'sess-name ellipsis' }, sessionName(meta)), h('span', { class: 'block ellipsis muted' }, `${label('project', meta.project)} · ${meta.tool}`)), h('span', { class: 'hide-sm num' }, `${fmt.md(localDateKey(meta.start))} ${fmt.clock(meta.start)}`), - h('span', { class: 'hide-sm' }, fmt.duration(sessionMinutes(meta))), + h('span', { class: 'hide-sm', title: t('activeTimeHelp') }, fmt.duration(activeMinutes(meta))), h('span', { class: 'hide-sm num right' }, fmt.int(totals.messages)), h('span', { class: 'hide-sm models' }, - models.slice(0, 2).map(([model]) => h('span', { class: 'model-chip' }, swatch(seriesColor('model', model)), h('span', { class: 'ellipsis' }, label('model', model)))), + models.slice(0, 2).map(([model]) => h('span', { class: 'model-chip', title: label('model', model) }, + swatch(seriesColor('model', model)), h('span', { class: 'model-name' }, label('model', model)))), models.length > 2 ? h('span', { class: 'muted' }, `+${models.length - 2}`) : null), secondary('hide-sm'), h('span', { class: 'hide-sm num right' }, fmt.percent(cacheShare(totals), 0)), @@ -879,10 +967,13 @@ function sessionDetail(item) { h('div', {}, h('p', { class: 'section-no' }, t('sessionDetails')), h('h2', {}, sessionName(meta)), - h('p', { class: 'small', style: 'margin-top:6px;color:var(--ink-2)' }, `${meta.tool} · ${fmt.md(localDateKey(meta.start))} ${fmt.clock(meta.start)} – ${end} · ${fmt.duration(sessionMinutes(meta))}`), + h('p', { class: 'small', style: 'margin-top:6px;color:var(--ink-2)', title: t('activeTimeHelp') }, + `${meta.tool} · ${fmt.md(localDateKey(meta.start))} ${fmt.clock(meta.start)} – ${end} · ${t('activeTime')} ${fmt.duration(activeMinutes(meta))}`), h('p', { class: 'mono small muted ellipsis', title: projectTitle(meta.project) }, snapshot.projects.has(meta.project) ? meta.project : label('project', meta.project))), h('div', { class: 'detail-stats' }, - [[t('cost'), fmt.cost(totals.cost)], [t('tokens'), fmt.compact(totalTokens(totals))], [t('replies'), fmt.int(totals.messages)], [t('toolCalls'), fmt.int(totals.toolCalls)]] + [[t('cost'), fmt.cost(totals.cost)], [t('tokens'), fmt.compact(totalTokens(totals))], + [t('replies'), fmt.int(totals.messages)], [t('toolCalls'), fmt.int(totals.toolCalls)], + [t('filesRead'), fmt.int(totals.filesRead)], [t('filesEdited'), fmt.int(totals.filesEdited)]] .map(([name, value]) => h('div', { class: 'stat' }, h('p', { class: 'small muted' }, name), h('p', {}, value)))), h('div', {}, h('div', { class: 'section-head' }, h('h3', {}, granularity === 'hour' ? t('tokensPerHour') : t('tokensPerDay')), h('span', { class: 'small muted' }, t('stackedByModel'))), @@ -896,9 +987,14 @@ function sessionDetail(item) { h('div', {}, h('h3', {}, t('models')), models.map(([model, modelTotals]) => h('div', { class: 'detail-model' }, - h('span', { class: 'rank-name' }, swatch(seriesColor('model', model)), h('span', { class: 'ellipsis' }, label('model', model))), - h('span', { class: 'num muted' }, `${t('replyCount')(fmt.int(modelTotals.messages))} · ${fmt.compact(totalTokens(modelTotals))}`), - h('span', { class: 'num right', style: 'font-weight:600' }, modelTotals.cost > 0 ? fmt.cost(modelTotals.cost) : t('noPrice'))))), + h('span', { class: 'rank-name', title: label('model', model) }, + swatch(seriesColor('model', model)), h('span', { class: 'model-name' }, label('model', model))), + [...new Set(rows.filter(row => row.model === model && row.originalModel !== model).map(row => row.originalModel))] + .map(original => h('span', { class: 'small muted model-name' }, original)), + h('span', { class: 'detail-model-stats' }, + h('span', { class: 'num muted' }, `${t('replyCount')(fmt.int(modelTotals.messages))} · ${fmt.compact(totalTokens(modelTotals))}`), + h('span', { class: 'num muted' }, t('cacheShort')(fmt.percent(cacheShare(modelTotals), 0))), + h('span', { class: 'num', style: 'font-weight:600' }, modelTotals.cost > 0 ? fmt.cost(modelTotals.cost) : t('noPrice')))))), h('div', {}, h('h3', {}, t('tokenMix')), composition(totals, 'comp'), compositionLegend(totals)), h('div', { class: 'detail-id' }, h('span', { class: 'mono small muted ellipsis', title: meta.id }, meta.id), copy), h('button', { @@ -916,13 +1012,17 @@ function breakdownView(view) { // Token counts are compact so every column fits; the exact count is the cell's title. const tokens = value => (value ? h('td', { title: fmt.int(value) }, fmt.compact(value)) : h('td', {}, '—')); const share = totals => h('td', {}, amount(view.totals) > 0 ? fmt.percent(amount(totals) / amount(view.totals)) : '—'); - const costColumn = ['cost', totals => h('td', {}, fmt.cost(totals.cost))]; - const tokensColumn = ['totalTokens', totals => tokens(totalTokens(totals))]; - // The selected metric leads the table and its share follows it. + // Each metric has a value column and a share column; the selected metric + // leads the table with its share, the other two follow. + const metricColumns = { + cost: ['cost', totals => h('td', {}, fmt.cost(totals.cost))], + tokens: ['totalTokens', totals => tokens(totalTokens(totals))], + requests: ['replies', totals => h('td', {}, fmt.int(totals.messages))], + }; + const shareNames = { cost: 'shareOfCost', tokens: 'shareOfTokens', requests: 'shareOfRequests' }; + const rest = Object.keys(metricColumns).filter(name => name !== state.metric); const columns = [ - ...(state.metric === 'cost' - ? [costColumn, ['shareOfCost', share], tokensColumn] - : [tokensColumn, ['shareOfTokens', share], costColumn]), + metricColumns[state.metric], [shareNames[state.metric], share], ...rest.map(name => metricColumns[name]), ['input', totals => tokens(totals.inputTokens)], ['cached', totals => tokens(totals.cachedTokens)], ['output', totals => tokens(totals.outputTokens)], @@ -930,7 +1030,11 @@ function breakdownView(view) { ['cacheShare', totals => h('td', {}, fmt.percent(cacheShare(totals)))], ['averagePrice', totals => h('td', {}, totals.cost > 0 && totalTokens(totals) > 0 ? fmt.cost((totals.cost / totalTokens(totals)) * 1e6) : '—')], ['sessions', totals => h('td', {}, fmt.int(totals.sessions.size))], - ['replies', totals => h('td', {}, fmt.int(totals.messages))], + ['toolCalls', totals => h('td', {}, fmt.int(totals.toolCalls))], + ['filesRead', totals => h('td', {}, fmt.int(totals.filesRead))], + ['filesChanged', totals => h('td', { title: t('fileChanges')(fmt.int(totals.filesAdded), fmt.int(totals.filesEdited)) }, + fmt.int(totals.filesEdited + totals.filesAdded))], + ['shellCommands', totals => h('td', {}, fmt.int(totals.terminalCommands))], ]; const cells = totals => columns.map(([, cell]) => cell(totals)); const exportValues = totals => { @@ -938,8 +1042,11 @@ function breakdownView(view) { cost: totals.cost, totalTokens: totalTokens(totals), input: totals.inputTokens, cached: totals.cachedTokens, output: totals.outputTokens, reasoning: totals.reasoningTokens, cacheShare: cacheShare(totals), sessions: totals.sessions.size, replies: totals.messages, + toolCalls: totals.toolCalls, filesRead: totals.filesRead, + filesChanged: totals.filesEdited + totals.filesAdded, shellCommands: totals.terminalCommands, shareOfCost: amount(view.totals) > 0 ? amount(totals) / amount(view.totals) : '', shareOfTokens: amount(view.totals) > 0 ? amount(totals) / amount(view.totals) : '', + shareOfRequests: amount(view.totals) > 0 ? amount(totals) / amount(view.totals) : '', averagePrice: totals.cost > 0 && totalTokens(totals) > 0 ? totals.cost / totalTokens(totals) * 1e6 : '', }; return columns.map(([name]) => values[name]); @@ -959,7 +1066,7 @@ function breakdownView(view) { if (dim !== 'period') { return h('button', { type: 'button', class: 'row-link', 'aria-pressed': String(state.filters[dim].has(key)), 'data-focus': `breakdown:${key}`, - title: dim === 'project' ? projectTitle(key) : null, onclick: () => toggleFilter(dim, key), + title: labelTitle(dim, key), onclick: () => toggleFilter(dim, key), }, label(dim, key)); } const text = periodLabel(key, view.granularity, false, view.range); diff --git a/src/web/data.mjs b/src/web/data.mjs index afb33adf..7cc8fdbb 100644 --- a/src/web/data.mjs +++ b/src/web/data.mjs @@ -4,6 +4,23 @@ export const OTHER = '\u0000other'; export const SESSION_PAGE_SIZE = 50; +// Only user-configured aliases change display grouping; cached rows stay raw. +export function mapModelAliases(rows, aliases = {}) { + const mapping = new Map(Object.entries(aliases)); + return rows.map(row => { + let model = row.model; + const base = model.slice(model.lastIndexOf('/') + 1); + if (!mapping.has(model) && mapping.has(base)) model = base; + const visited = new Set(); + while (mapping.has(model)) { + if (visited.has(model)) return { ...row, originalModel: row.model }; + visited.add(model); + model = mapping.get(model); + } + return { ...row, model, originalModel: row.model }; + }); +} + // Quote every field and keep labels from being interpreted as spreadsheet formulas. export function csv(rows) { return rows.map(row => row.map(value => { @@ -18,7 +35,9 @@ export function rowTokens(row) { } export function metricValue(row, metric) { - return metric === 'cost' ? row.cost : rowTokens(row); + if (metric === 'cost') return row.cost; + if (metric === 'requests') return row.messages; + return rowTokens(row); } export function localDateKey(date) { @@ -93,13 +112,20 @@ export function bucketKeys(range, granularity, now) { } function emptyTotals() { - return { cost: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, reasoningTokens: 0, messages: 0, toolCalls: 0, sessions: new Set(), dates: new Set() }; + return { + cost: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, reasoningTokens: 0, messages: 0, toolCalls: 0, + filesRead: 0, filesEdited: 0, filesAdded: 0, terminalCommands: 0, sessions: new Set(), dates: new Set(), + }; } -const SUMMED = ['cost', 'inputTokens', 'outputTokens', 'cachedTokens', 'reasoningTokens', 'messages', 'toolCalls']; +const SUMMED = [ + 'cost', 'inputTokens', 'outputTokens', 'cachedTokens', 'reasoningTokens', 'messages', 'toolCalls', + 'filesRead', 'filesEdited', 'filesAdded', 'terminalCommands', +]; function addRow(totals, row) { - for (const field of SUMMED) totals[field] += row[field]; + // Snapshots written before an activity field existed simply contribute zero. + for (const field of SUMMED) totals[field] += row[field] ?? 0; totals.sessions.add(row.session); totals.dates.add(row.date); return totals; @@ -189,3 +215,27 @@ export function median(values) { const sorted = [...values].sort((a, b) => a - b); return sorted[Math.floor(sorted.length / 2)]; } + +// Value at `fraction` of the sorted values; 0.9 is the p90. +export function percentile(values, fraction) { + if (!values.length) return 0; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1))]; +} + +// Session shape bands. The last band is open-ended, and `duration` is minutes. +export const SESSION_SHAPES = { + messages: [[1, 5], [6, 15], [16, 30], [31, 60], [61, 120], [121, Infinity]], + duration: [[0, 10], [11, 30], [31, 60], [61, 120], [121, 360], [361, Infinity]], + toolCalls: [[0, 0], [1, 5], [6, 20], [21, 50], [51, 100], [101, Infinity]], +}; + +// How many values land in each [min, max] band, in band order. +export function histogram(values, buckets) { + const counts = new Array(buckets.length).fill(0); + for (const value of values) { + const index = buckets.findIndex(([min, max]) => value >= min && value <= max); + if (index !== -1) counts[index] += 1; + } + return counts; +} diff --git a/src/web/data.test.mjs b/src/web/data.test.mjs index 190e873f..2136968c 100644 --- a/src/web/data.test.mjs +++ b/src/web/data.test.mjs @@ -6,15 +6,35 @@ import { messages } from './locales.mjs'; import { OTHER, localDateKey, metricValue, periodKey, selectRange, inRange, matchesFilters, granularityOptions, bucketKeys, sumRows, groupRows, totalTokens, cacheShare, colorSlots, stackedSeries, weekdayHourGrid, niceStep, topTenthShare, median, csv, + percentile, SESSION_SHAPES, histogram, mapModelAliases, } from './data.mjs'; const row = (overrides = {}) => ({ date: '2025-12-31', hour: 23, model: 'a', project: 'p', tool: 't', session: 's', - inputTokens: 10, outputTokens: 3, cachedTokens: 2, reasoningTokens: 4, cost: 0.1, messages: 1, toolCalls: 0, ...overrides, + inputTokens: 10, outputTokens: 3, cachedTokens: 2, reasoningTokens: 4, cost: 0.1, messages: 1, toolCalls: 0, + filesRead: 0, filesEdited: 0, filesAdded: 0, terminalCommands: 0, ...overrides, }); const filters = (overrides = {}) => ({ model: new Set(), project: new Set(), tool: new Set(), ...overrides }); const now = new Date(2026, 0, 2, 12, 30); +test('configured model aliases merge grouping and filters while retaining original names', () => { + const raw = [row({ model: 'group/auto-flash' }), row({ model: 'vendor/flash-v1' }), row({ model: 'flash' }), row({ model: 'unknown' })]; + const rows = mapModelAliases(raw, { 'group/auto-flash': 'flash-v1', 'flash-v1': 'flash' }); + assert.deepEqual(rows.map(row => row.model), ['flash', 'flash', 'flash', 'unknown']); + assert.deepEqual(rows.map(row => row.originalModel), raw.map(row => row.model)); + assert.equal(groupRows(rows, row => row.model).get('flash').messages, 3); + assert.equal(rows.filter(row => matchesFilters(row, filters({ model: new Set(['flash']) }))).length, 3); + assert.equal(sumRows(rows).cost, sumRows(raw).cost); + assert.equal(raw[0].model, 'group/auto-flash'); + assert.equal(mapModelAliases(raw)[0].model, 'group/auto-flash'); +}); + +test('exact aliases win over bare names and cycles preserve the original name', () => { + const aliases = { 'vendor/a': 'exact', a: 'bare', x: 'y', y: 'x', self: 'self' }; + const rows = mapModelAliases(['vendor/a', 'x', 'vendor/y', 'self', 'toString'].map(model => row({ model })), aliases); + assert.deepEqual(rows.map(row => row.model), ['exact', 'x', 'vendor/y', 'self', 'toString']); +}); + test('week and month keys cross year boundaries', () => { assert.equal(periodKey('2025-12-31', 23, 'week'), '2025-12-29'); assert.equal(periodKey('2026-01-04', 0, 'week'), '2025-12-29'); @@ -70,13 +90,17 @@ test('stacked series preserve totals and keep entity colors when filtered', () = }); test('totals, grouping, cache share, and concentration', () => { - const rows = [row({ session: 'x' }), row({ session: 'y', cost: 0.3, cachedTokens: 30 })]; + const rows = [row({ session: 'x' }), row({ session: 'y', cost: 0.3, cachedTokens: 30, filesRead: 2, filesEdited: 1, terminalCommands: 3 })]; const totals = sumRows(rows); assert.equal(totals.sessions.size, 2); assert.equal(cacheShare(totals), 32 / 52); + assert.deepEqual([totals.filesRead, totals.filesEdited, totals.filesAdded, totals.terminalCommands], [2, 1, 0, 3]); assert.equal(groupRows(rows, item => item.session).get('y').cost, 0.3); assert.deepEqual(topTenthShare([90, 5, 1, 1, 1, 1, 1, 0, 0, 0]), { count: 1, share: 0.9 }); assert.equal(median([5, 1, 3]), 3); + assert.equal(percentile([], 0.9), 0); + assert.equal(percentile([5, 1, 3], 0.9), 5); + assert.equal(percentile([1, 2, 3, 4], 0.5), 2); }); test('weekday grid is Monday-first and axis steps are round', () => { @@ -88,6 +112,27 @@ test('weekday grid is Monday-first and axis steps are round', () => { assert.equal(niceStep(0), 1); }); +test('replies metric counts model responses', () => { + assert.equal(metricValue(row({ messages: 3 }), 'requests'), 3); +}); + +test('every locale presents the requests metric as replies', () => { + for (const locale of Object.values(messages)) assert.equal(locale.requests, locale.replies); +}); + +test('session shape bands cover every value without gaps or overlap', () => { + // Bands are ascending and contiguous, with the last one open-ended. + for (const bands of Object.values(SESSION_SHAPES)) { + assert.equal(bands.at(-1)[1], Infinity); + for (let i = 1; i < bands.length; i++) assert.equal(bands[i][0], bands[i - 1][1] + 1); + } + // 0 falls in no message band (messages start at 1), so it is dropped. + assert.deepEqual(histogram([0, 1, 5, 6, 15, 16, 30, 31, 60, 61, 120, 121, 9999], SESSION_SHAPES.messages), [2, 2, 2, 2, 2, 2]); + assert.deepEqual(histogram([0, 1, 5, 6, 20, 21, 50, 51, 100, 101], SESSION_SHAPES.toolCalls), [1, 2, 2, 2, 2, 1]); + // Values outside every band are dropped rather than misfiled. + assert.deepEqual(histogram([-1, 3], [[0, 10]]), [1]); +}); + test('model rankings guard cost-only and reasoning-only rows', () => { const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); const rankSource = source.slice(source.indexOf('function rankSub('), source.indexOf('function rankCard(')); @@ -132,9 +177,10 @@ test('bookmarked dates must be valid calendar dates', () => { const readHash = runInNewContext(`${stateSource}\nreadHash`, { location, URLSearchParams, localDateKey, noon: key => new Date(`${key}T12:00:00`), VIEWS: ['overview', 'sessions', 'breakdown'], RANGES: ['today', '7', '30', '90', 'all'], - METRICS: ['cost', 'tokens'], DIMENSIONS: ['tool', 'model', 'project'], - GRANULARITIES: ['hour', 'day', 'week', 'month'], SORTS: ['recent', 'cost', 'tokens', 'duration'], + METRICS: ['cost', 'tokens', 'requests'], DIMENSIONS: ['tool', 'model', 'project'], + GRANULARITIES: ['hour', 'day', 'week', 'month'], SORTS: ['recent', 'cost', 'tokens', 'requests', 'duration'], BREAKDOWNS: ['period', 'project', 'model', 'tool'], + SHAPES: ['messages', 'duration', 'toolCalls'], }); for (const day of ['2026-02-30', '2026-99-01', 'garbage']) { location.hash = `#day=${day}`; diff --git a/src/web/locales.mjs b/src/web/locales.mjs index af61d27a..9ab5c702 100644 --- a/src/web/locales.mjs +++ b/src/web/locales.mjs @@ -11,17 +11,18 @@ export const messages = { tool: '工具', model: '模型', project: '專案', period: '時段', searchIn: name => `搜尋${name}`, noMatches: '沒有符合的選項', clearSelection: '清除選擇', removeFilter: name => `移除篩選:${name}`, clearFilters: '清除篩選', - metric: '指標', cost: '成本', tokens: 'Token', - estimatedCost: '估算成本(按 API 價格)', totalTokens: 'Token 總量', cacheShare: '快取占比', + metric: '指標', cost: '成本', tokens: 'Token', requests: '回覆', + estimatedCost: '估算成本(按 API 價格)', totalTokens: 'Token 總量', cacheShare: '快取占比', requestsSub: avg => `平均 ${avg} token / 次回覆`, + activeTime: '活躍時長', activeTimeHelp: '活躍時長只計相鄰訊息間隔少於 5 分鐘的部分。', noComparison: '全部歷史,沒有比較週期', previousZero: '上一週期為 0', unchanged: '持平', points: '個百分點', dailyAverage: (cost, active, days) => `日均 ${cost} · ${active} / ${days} 天有使用`, asOf: time => `截至 ${time}`, wholeDay: '當天合計', - sessionsSub: (duration, replies) => `中位時長 ${duration} · ${replies} 則回覆`, noSessions: '沒有工作階段', + sessionsSub: (duration, replies) => `中位活躍時長 ${duration} · ${replies} 則回覆`, noSessions: '沒有工作階段', cacheSub: '輸入 token 中由快取提供的比例', cached: '快取', input: '輸入', output: '輸出', reasoning: '推理', costMayBeLow: '成本可能偏低', unpriced: (models, tokens) => `${models} 的 ${tokens} token 成本為 $0,可能缺少價格。`, listSeparator: '、', viewInBreakdown: '在明細中查看', every: { hour: '每小時', day: '每日', week: '每週', month: '每月' }, - trendTitle: (every, metric) => `${every}${metric === 'cost' ? '成本' : ' Token'}`, + trendTitle: (every, metric) => `${every}${metric}`, stackedBy: (dimension, range) => `按${dimension}堆疊 · ${range}`, group: '分組', interval: '粒度', intervals: { hour: '小時', day: '日', week: '週', month: '月' }, viewTable: '以表格查看', drillHint: '點選長條下鑽到當天,按小時查看。', emptyRange: '所選範圍和篩選下沒有用量', @@ -33,21 +34,31 @@ export const messages = { rhythm: '工作節奏', peak: (day, hour) => `高峰:${day} ${hour}:00`, noUsage: '沒有用量', less: '少', more: '多', rhythmNote: '按本機時間,星期 × 小時', rhythmLabel: '按星期和小時的用量熱圖。用方向鍵逐格查看。', shareOfRange: share => `占所選範圍 ${share}`, tokenCount: count => `${count} token`, - topByCost: '成本最高的工作階段', topByTokens: 'Token 最多的工作階段', - concentration: (count, share) => `前 10% 的工作階段(${count} 個)占 ${share} 成本`, viewAllSessions: '查看全部工作階段', - searchSessions: '搜尋名稱、專案或工具', sort: '排序', recent: '最近', durationLabel: '時長', + topByCost: '成本最高的工作階段', topByTokens: 'Token 最多的工作階段', topByRequests: '回覆最多的工作階段', + concentration: (count, share, metric) => `前 10% 的工作階段(${count} 個)占 ${share} ${metric}`, viewAllSessions: '查看全部工作階段', + searchSessions: '搜尋名稱、專案或工具', sort: '排序', recent: '最近', sessionCountIn: (count, range) => `${count} 個工作階段 · ${range}`, session: '工作階段', started: '開始', replies: '回覆', models: '模型', + sessionSummary: '工作階段摘要', totalActive: '總活躍時長', costPerReply: '每次回覆成本', tokensPerReply: '每次回覆 Token', projects: '專案', + sessionShape: '工作階段形態', shapeSub: total => `${total} 個工作階段`, + sessionSpread: (median, p90) => `中位 ${median} · p90 ${p90}`, + shapeNote: '依所選範圍與篩選,統計各區間的工作階段數。', shapeBand: (band, count) => `${band}:${count} 個`, + bandRange: (min, max) => `${min}–${max}`, bandPlus: min => `${min}+`, + workActivity: '工作活動', activitySub: sessions => `${sessions} 個工作階段`, + filesRead: '檔案讀取', filesChanged: '檔案操作', filesEdited: '檔案編輯', shellCommands: '終端命令', + perReply: count => `每次回覆 ${count}`, perSession: count => `每個工作階段 ${count}`, + fileChanges: (added, edited) => `新增 ${added} · 編輯 ${edited}`, + activityNote: '統計的是紀錄到的工具操作,不是唯一檔案數;覆蓋範圍取決於分析器。', showing: (shown, total) => `顯示 ${shown} / ${total}`, loadMore: '載入更多', noMatchingSessions: '沒有符合的工作階段', untitled: '未命名工作階段', sessionDetails: '工作階段詳情 · 整個工作階段', tokensPerHour: '每小時 Token', tokensPerDay: '每日 Token', stackedByModel: '按模型堆疊', tokenMix: 'Token 組成', toolCalls: '工具呼叫', replyCount: count => `${count} 則`, copy: '複製 ID', copied: '已複製', onlyThisProject: '只看此專案', selectSession: '選擇一個工作階段查看詳情。', by: '按', periodBy: interval => `時段(按${interval})`, rowsIn: (count, range) => `${count} 列 · ${range}`, - onlyActivePeriods: '只列有用量的時段', shareOfCost: '成本占比', shareOfTokens: 'Token 占比', averagePrice: '均價 / 1M', total: '合計', - sortedBy: { cost: '按成本排序', tokens: '按 Token 排序' }, + onlyActivePeriods: '只列有用量的時段', shareOfCost: '成本占比', shareOfTokens: 'Token 占比', shareOfRequests: '回覆占比', averagePrice: '均價 / 1M', total: '合計', + sortedBy: { cost: '按成本排序', tokens: '按 Token 排序', requests: '按回覆數排序' }, minutes: minutes => `${minutes} 分鐘`, hoursMinutes: (hours, minutes) => `${hours} 小時${minutes ? ` ${minutes} 分` : ''}`, exportCsv: '匯出 CSV', - metricNote: 'Token 總量 = 輸入 + 輸出 + 快取,與 TUI 一致;推理 token 另外列出。成本按各模型公開 API 價格估算,訂閱使用者的實際支出可能不同。', + metricNote: 'Token 總量 = 輸入 + 輸出 + 快取,與 TUI 一致;推理 token 另外列出。回覆數是模型實際產生的回覆次數。成本按各模型公開 API 價格估算,訂閱使用者的實際支出可能不同。', }, zh: { exportCsv: '导出 CSV', @@ -62,17 +73,18 @@ export const messages = { tool: '工具', model: '模型', project: '项目', period: '时段', searchIn: name => `搜索${name}`, noMatches: '没有匹配的选项', clearSelection: '清除选择', removeFilter: name => `移除筛选:${name}`, clearFilters: '清除筛选', - metric: '指标', cost: '成本', tokens: 'Token', - estimatedCost: '估算成本(按 API 价)', totalTokens: 'Token 总量', cacheShare: '缓存占比', + metric: '指标', cost: '成本', tokens: 'Token', requests: '回复', + estimatedCost: '估算成本(按 API 价)', totalTokens: 'Token 总量', cacheShare: '缓存占比', requestsSub: avg => `平均 ${avg} token / 次回复`, + activeTime: '活跃时长', activeTimeHelp: '活跃时长只计相邻消息间隔少于 5 分钟的部分。', noComparison: '全部历史,没有对比周期', previousZero: '上一周期为 0', unchanged: '持平', points: '个百分点', dailyAverage: (cost, active, days) => `日均 ${cost} · ${active} / ${days} 天有使用`, asOf: time => `截至 ${time}`, wholeDay: '当天合计', - sessionsSub: (duration, replies) => `中位时长 ${duration} · ${replies} 条回复`, noSessions: '没有会话', + sessionsSub: (duration, replies) => `中位活跃时长 ${duration} · ${replies} 条回复`, noSessions: '没有会话', cacheSub: '输入侧 token 中由缓存提供的比例', cached: '缓存', input: '输入', output: '输出', reasoning: '推理', costMayBeLow: '成本可能偏低', unpriced: (models, tokens) => `${models} 的 ${tokens} token 成本为 $0,可能缺少价格。`, listSeparator: '、', viewInBreakdown: '在明细中查看', every: { hour: '每小时', day: '每日', week: '每周', month: '每月' }, - trendTitle: (every, metric) => `${every}${metric === 'cost' ? '成本' : ' Token'}`, + trendTitle: (every, metric) => `${every}${metric}`, stackedBy: (dimension, range) => `按${dimension}堆叠 · ${range}`, group: '分组', interval: '粒度', intervals: { hour: '小时', day: '日', week: '周', month: '月' }, viewTable: '以表格查看', drillHint: '点击柱子下钻到当天,按小时查看。', emptyRange: '所选范围和筛选下没有用量', @@ -84,20 +96,30 @@ export const messages = { rhythm: '工作节奏', peak: (day, hour) => `高峰:${day} ${hour}:00`, noUsage: '没有用量', less: '少', more: '多', rhythmNote: '按本地时间,星期 × 小时', rhythmLabel: '按星期和小时的用量热力图。用方向键逐格查看。', shareOfRange: share => `占所选范围 ${share}`, tokenCount: count => `${count} token`, - topByCost: '成本最高的会话', topByTokens: 'Token 最多的会话', - concentration: (count, share) => `前 10% 的会话(${count} 个)占 ${share} 成本`, viewAllSessions: '查看全部会话', - searchSessions: '搜索会话名、项目或工具', sort: '排序', recent: '最近', durationLabel: '时长', + topByCost: '成本最高的会话', topByTokens: 'Token 最多的会话', topByRequests: '回复最多的会话', + concentration: (count, share, metric) => `前 10% 的会话(${count} 个)占 ${share} ${metric}`, viewAllSessions: '查看全部会话', + searchSessions: '搜索会话名、项目或工具', sort: '排序', recent: '最近', sessionCountIn: (count, range) => `${count} 个会话 · ${range}`, session: '会话', started: '开始', replies: '回复', models: '模型', + sessionSummary: '会话摘要', totalActive: '总活跃时长', costPerReply: '单次回复成本', tokensPerReply: '单次回复 Token', projects: '项目', + sessionShape: '会话形态', shapeSub: total => `${total} 个会话`, + sessionSpread: (median, p90) => `中位 ${median} · p90 ${p90}`, + shapeNote: '按所选范围和筛选,统计各区间内的会话数。', shapeBand: (band, count) => `${band}:${count} 个`, + bandRange: (min, max) => `${min}–${max}`, bandPlus: min => `${min}+`, + workActivity: '工作活动', activitySub: sessions => `${sessions} 个会话`, + filesRead: '文件读取', filesChanged: '文件操作', filesEdited: '文件编辑', shellCommands: '终端命令', + perReply: count => `每次回复 ${count}`, perSession: count => `每个会话 ${count}`, + fileChanges: (added, edited) => `新增 ${added} · 编辑 ${edited}`, + activityNote: '统计的是记录到的工具操作,不是唯一文件数;覆盖范围取决于分析器。', showing: (shown, total) => `显示 ${shown} / ${total}`, loadMore: '加载更多', noMatchingSessions: '没有匹配的会话', untitled: '未命名会话', sessionDetails: '会话详情 · 整个会话', tokensPerHour: '每小时 Token', tokensPerDay: '每日 Token', stackedByModel: '按模型堆叠', tokenMix: 'Token 构成', toolCalls: '工具调用', replyCount: count => `${count} 条`, copy: '复制 ID', copied: '已复制', onlyThisProject: '只看这个项目', selectSession: '选择一个会话查看详情。', by: '按', periodBy: interval => `时段(按${interval})`, rowsIn: (count, range) => `${count} 行 · ${range}`, - onlyActivePeriods: '只列有用量的时段', shareOfCost: '成本占比', shareOfTokens: 'Token 占比', averagePrice: '均价 / 1M', total: '合计', - sortedBy: { cost: '按成本排序', tokens: '按 Token 排序' }, + onlyActivePeriods: '只列有用量的时段', shareOfCost: '成本占比', shareOfTokens: 'Token 占比', shareOfRequests: '回复占比', averagePrice: '均价 / 1M', total: '合计', + sortedBy: { cost: '按成本排序', tokens: '按 Token 排序', requests: '按回复数排序' }, minutes: minutes => `${minutes} 分钟`, hoursMinutes: (hours, minutes) => `${hours} 小时${minutes ? ` ${minutes} 分` : ''}`, - metricNote: 'Token 总量 = 输入 + 输出 + 缓存,与 TUI 口径一致;推理 token 单独列示。成本按各模型公开 API 价格估算,订阅用户的实际支出可能不同。', + metricNote: 'Token 总量 = 输入 + 输出 + 缓存,与 TUI 口径一致;推理 token 单独列示。回复数是模型实际返回的回复次数。成本按各模型公开 API 价格估算,订阅用户的实际支出可能不同。', }, en: { exportCsv: 'Export CSV', @@ -112,17 +134,18 @@ export const messages = { tool: 'Tool', model: 'Model', project: 'Project', period: 'Period', searchIn: name => `Filter ${name.toLowerCase()}s`, noMatches: 'No matches', clearSelection: 'Clear selection', removeFilter: name => `Remove filter: ${name}`, clearFilters: 'Clear filters', - metric: 'Metric', cost: 'Cost', tokens: 'Tokens', - estimatedCost: 'Estimated cost (API prices)', totalTokens: 'Total tokens', cacheShare: 'Cache share', + metric: 'Metric', cost: 'Cost', tokens: 'Tokens', requests: 'Replies', + estimatedCost: 'Estimated cost (API prices)', totalTokens: 'Total tokens', cacheShare: 'Cache share', requestsSub: avg => `${avg} tokens per reply on average`, + activeTime: 'Active time', activeTimeHelp: 'Active time counts gaps under 5 minutes between messages.', noComparison: 'All history, nothing to compare', previousZero: 'previous period was 0', unchanged: 'unchanged', points: 'pts', dailyAverage: (cost, active, days) => `${cost} per day · active ${active} of ${days} days`, asOf: time => `As of ${time}`, wholeDay: 'Whole day', - sessionsSub: (duration, replies) => `Median ${duration} · ${replies} replies`, noSessions: 'No sessions', + sessionsSub: (duration, replies) => `Median active ${duration} · ${replies} replies`, noSessions: 'No sessions', cacheSub: 'Share of prompt tokens served from cache', cached: 'Cached', input: 'Input', output: 'Output', reasoning: 'Reasoning', costMayBeLow: 'Cost may be understated', unpriced: (models, tokens) => `${tokens} tokens from ${models} are priced at $0, likely missing prices.`, listSeparator: ', ', viewInBreakdown: 'View in breakdown', every: { hour: 'Hourly', day: 'Daily', week: 'Weekly', month: 'Monthly' }, - trendTitle: (every, metric) => `${every} ${metric === 'cost' ? 'cost' : 'tokens'}`, + trendTitle: (every, metric) => `${every} ${metric.toLowerCase()}`, stackedBy: (dimension, range) => `Stacked by ${dimension.toLowerCase()} · ${range}`, group: 'Group', interval: 'Interval', intervals: { hour: 'Hour', day: 'Day', week: 'Week', month: 'Month' }, viewTable: 'View as table', drillHint: 'Click a bar to drill into that day by hour.', emptyRange: 'No usage for this range and filters', @@ -134,19 +157,29 @@ export const messages = { rhythm: 'Rhythm', peak: (day, hour) => `Peak: ${day} ${hour}:00`, noUsage: 'No usage', less: 'Less', more: 'More', rhythmNote: 'Local time, weekday × hour', rhythmLabel: 'Usage heatmap by weekday and hour. Use the arrow keys to read each cell.', shareOfRange: share => `${share} of the range`, tokenCount: count => `${count} tokens`, - topByCost: 'Most expensive sessions', topByTokens: 'Largest sessions by tokens', - concentration: (count, share) => `Top 10% of sessions (${count}) hold ${share} of cost`, viewAllSessions: 'View all sessions', - searchSessions: 'Search name, project, or tool', sort: 'Sort', recent: 'Recent', durationLabel: 'Duration', + topByCost: 'Most expensive sessions', topByTokens: 'Largest sessions by tokens', topByRequests: 'Sessions with most replies', + concentration: (count, share, metric) => `Top 10% of sessions (${count}) hold ${share} of ${metric}`, viewAllSessions: 'View all sessions', + searchSessions: 'Search name, project, or tool', sort: 'Sort', recent: 'Recent', sessionCountIn: (count, range) => `${count} sessions · ${range}`, session: 'Session', started: 'Started', replies: 'Replies', models: 'Models', + sessionSummary: 'Session summary', totalActive: 'Total active', costPerReply: 'Cost / reply', tokensPerReply: 'Tokens / reply', projects: 'Projects', + sessionShape: 'Session shape', shapeSub: total => `${total} sessions`, + sessionSpread: (median, p90) => `median ${median} · p90 ${p90}`, + shapeNote: 'Number of sessions in each band for the selected range and filters.', shapeBand: (band, count) => `${band}: ${count}`, + bandRange: (min, max) => `${min}–${max}`, bandPlus: min => `${min}+`, + workActivity: 'Work activity', activitySub: sessions => `${sessions} sessions`, + filesRead: 'File reads', filesChanged: 'File operations', filesEdited: 'File edits', shellCommands: 'Shell commands', + perReply: count => `${count} per reply`, perSession: count => `${count} per session`, + fileChanges: (added, edited) => `${added} added · ${edited} edited`, + activityNote: 'Recorded tool operations, not unique files; coverage depends on the analyzer.', showing: (shown, total) => `Showing ${shown} of ${total}`, loadMore: 'Load more', noMatchingSessions: 'No matching sessions', untitled: 'Untitled session', sessionDetails: 'Session details · whole session', tokensPerHour: 'Tokens per hour', tokensPerDay: 'Tokens per day', stackedByModel: 'Stacked by model', tokenMix: 'Token mix', toolCalls: 'Tool calls', replyCount: count => `${count} replies`, copy: 'Copy ID', copied: 'Copied', onlyThisProject: 'Show only this project', selectSession: 'Select a session to see its details.', by: 'By', periodBy: interval => `Period (${interval.toLowerCase()})`, rowsIn: (count, range) => `${count} rows · ${range}`, - onlyActivePeriods: 'only periods with usage', shareOfCost: 'Share of cost', shareOfTokens: 'Share of tokens', averagePrice: 'Avg / 1M', total: 'Total', - sortedBy: { cost: 'sorted by cost', tokens: 'sorted by tokens' }, + onlyActivePeriods: 'only periods with usage', shareOfCost: 'Share of cost', shareOfTokens: 'Share of tokens', shareOfRequests: 'Share of replies', averagePrice: 'Avg / 1M', total: 'Total', + sortedBy: { cost: 'sorted by cost', tokens: 'sorted by tokens', requests: 'sorted by replies' }, minutes: minutes => `${minutes} min`, hoursMinutes: (hours, minutes) => `${hours} h${minutes ? ` ${minutes} min` : ''}`, - metricNote: 'Total tokens = input + output + cached, matching the TUI; reasoning tokens are listed separately. Costs are estimated from public API prices and may differ from subscription spending.', + metricNote: 'Total tokens = input + output + cached, matching the TUI; reasoning tokens are listed separately. Replies count model responses. Costs are estimated from public API prices and may differ from subscription spending.', }, }; diff --git a/src/web/style.css b/src/web/style.css index f37bb6e1..6f37d1b7 100644 --- a/src/web/style.css +++ b/src/web/style.css @@ -92,7 +92,7 @@ select.btn { padding-right: 6px; } .section-title h2 { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; } .section-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; } -.kpis { display: grid; grid-template-columns: minmax(0, 1.7fr) repeat(3, minmax(0, 1fr)); gap: 12px; } +.kpis { display: grid; grid-template-columns: minmax(0, 1.7fr) repeat(4, minmax(0, 1fr)); gap: 12px; } .kpi { display: flex; flex-direction: column; gap: 8px; padding: 18px 20px 16px; } .kpi-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } .kpi-label { font-size: 13px; font-weight: 500; color: var(--ink-2); } @@ -135,11 +135,11 @@ select.btn { padding-right: 6px; } .stack { display: flex; flex-direction: column-reverse; width: 64%; min-width: 2px; max-width: 24px; } .segment { flex: none; } .plot-empty { position: absolute; inset: 0; display: grid; place-items: center; font-size: 13px; color: var(--muted); } -.tip { position: absolute; z-index: 20; top: 6px; width: 230px; padding: 9px 11px; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; box-shadow: var(--shadow); font-size: 12px; pointer-events: none; } +.tip { position: absolute; z-index: 20; top: 6px; width: 280px; max-width: calc(100vw - 32px); padding: 9px 11px; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; box-shadow: var(--shadow); font-size: 12px; pointer-events: none; } .tip-value { margin-top: 2px; font-size: 16px; font-weight: 650; } .tip-rows { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; } .tip-row { display: flex; align-items: center; gap: 8px; } -.tip-row .ellipsis { flex: 1; color: var(--ink-2); } +.tip-label { min-width: 0; color: var(--ink-2); overflow-wrap: anywhere; } .tip-row .num { font-weight: 600; } .tip-key { flex: none; width: 12px; height: 2px; border-radius: 1px; } .x-axis { position: relative; height: 24px; } @@ -179,20 +179,41 @@ select.btn { padding-right: 6px; } .top-row .right { text-align: right; } .block { display: block; } +.session-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 1px; overflow: hidden; background: var(--line); } +.session-kpi { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 11px 15px 10px; background: var(--surface); } +.session-kpi-label { overflow: hidden; font-size: 11px; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } +.session-kpi-value { font-size: 19px; font-weight: 650; line-height: 1.15; letter-spacing: -0.02em; } +.sess-insights { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 12px; align-items: stretch; } .sess-layout { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 12px; align-items: start; } +.shape { padding: 14px 20px 12px; } +.shape .section-head { margin-bottom: 14px; } +.shape-bars { display: flex; gap: 8px; } +.shape-bar { display: flex; flex: 1 1 0; flex-direction: column; min-width: 0; } +/* Counts ride above each bar, so the bars need a fixed plot height of their own. */ +.shape-plot { position: relative; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; height: 150px; } +.shape-count { font-size: 12px; font-weight: 600; } +.shape-fill { width: 100%; max-width: 64px; min-height: 2px; border-radius: 4px 4px 0 0; background: var(--brand); } +.shape-label { padding-top: 6px; border-top: 1px solid var(--axis); font-size: 11px; color: var(--muted); text-align: center; white-space: nowrap; } +.activity { display: flex; flex-direction: column; padding: 14px 20px 12px; } +.activity .section-head { margin-bottom: 14px; } +.activity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: var(--line); } +.activity-stat { min-width: 0; padding: 10px 12px; background: var(--surface); } +.activity-value { margin-top: 2px; font-size: 22px; font-weight: 650; letter-spacing: -0.025em; } +.activity-note { margin-top: auto; padding-top: 12px; } .sess-list { padding: 12px 0 4px; } .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; padding: 0 16px 12px; } .search { position: relative; display: flex; align-items: center; flex: 1 1 240px; max-width: 360px; } .search svg { position: absolute; left: 10px; color: var(--muted); } .search input { width: 100%; min-height: 34px; padding: 0 10px 0 32px; border: 1px solid var(--line); border-radius: 7px; background: var(--page); font-size: 13px; } .toolbar .count-label { margin-left: auto; } -.sess-grid { display: grid; grid-template-columns: minmax(0, 2.6fr) 104px 92px 52px minmax(0, 1.4fr) 112px 52px 88px; gap: 12px; align-items: center; padding: 9px 16px; text-align: left; } +.sess-grid { display: grid; grid-template-columns: minmax(0, 2.8fr) 96px 84px 44px minmax(170px, 2.2fr) 100px 44px 80px; gap: 10px; align-items: center; padding: 9px 16px; text-align: left; } .sess-head { padding-top: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--line); font-size: 12px; color: var(--muted); } .sess-row { width: 100%; font-size: 12px; color: var(--ink-2); } .sess-name { display: block; font-size: 13px; font-weight: 500; color: var(--ink); } .right { text-align: right; } -.models { display: flex; flex-wrap: wrap; gap: 3px 10px; min-width: 0; } -.model-chip { display: inline-flex; align-items: center; gap: 5px; min-width: 0; } +.models { display: flex; flex-wrap: wrap; gap: 3px 12px; min-width: 0; } +.model-chip { display: inline-flex; align-items: flex-start; gap: 5px; max-width: 100%; min-width: 0; } +.model-name { min-width: 0; overflow-wrap: anywhere; } .model-chip .swatch { width: 8px; height: 8px; } .tokens-cell { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; color: var(--ink); } .mini-comp { display: flex; gap: 1px; width: 72px; height: 4px; } @@ -209,7 +230,9 @@ select.btn { padding-right: 6px; } .timeline .stack { width: 70%; } .timeline-labels { display: flex; gap: 4px; margin-top: 6px; } .timeline-labels span { flex: 1 1 0; min-width: 0; text-align: center; font-size: 10px; color: var(--muted); white-space: nowrap; } -.detail-model { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 12px; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 12px; } +.detail-model { display: flex; flex-direction: column; gap: 4px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 12px; } +.detail-model-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; } +.detail-model-stats .num:last-child { margin-left: auto; } .detail-id { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 12px; border-top: 1px solid var(--line); } .detail-id .btn { min-height: 28px; font-size: 12px; } .detail > .btn { justify-content: center; } @@ -230,6 +253,8 @@ tfoot td { font-weight: 600; color: var(--ink); } @media (max-width: 1100px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .kpis .hero { grid-column: 1 / -1; } + .session-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .sess-insights, .sess-layout { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 900px) { @@ -242,5 +267,6 @@ tfoot td { font-weight: 600; color: var(--ink); } .metric-group { margin-left: 0; } .tabs { gap: 20px; } .status { display: none; } + .session-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .sess-grid { grid-template-columns: minmax(0, 1fr) auto; padding-left: 12px; padding-right: 12px; } }