From f21342a72c76d12fcfc022e1632e80daa3c13d37 Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:21:13 +0300 Subject: [PATCH 1/3] Improve history project navigation and usage --- src/read/scan.rs | 115 ++++++++++++-- src/read/tui.rs | 337 +++++++++++++++++++++++++++++++--------- src/ui/mod.rs | 42 ++++- src/usage/mod.rs | 394 ++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 783 insertions(+), 105 deletions(-) diff --git a/src/read/scan.rs b/src/read/scan.rs index d114b4c..c5bef13 100644 --- a/src/read/scan.rs +++ b/src/read/scan.rs @@ -1,3 +1,7 @@ +use crate::usage::{ + normalize_project_key, project_identity_from_path, project_identity_from_tool_call, + PROJECT_IDENTITY_LINE_LIMIT, +}; use anyhow::{Context, Result}; use chrono::{DateTime, Local, Utc}; use serde_json::Value; @@ -7,7 +11,6 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::time::SystemTime; -const INDEX_LINE_LIMIT: usize = 128; const MAX_TITLE_CHARS: usize = 96; const MAX_TURN_PREVIEW_CHARS: usize = 220; @@ -74,6 +77,13 @@ struct SessionSummaryBuilder { repo_url: Option, model_provider: Option, model: Option, + project_cwds: BTreeMap, +} + +#[derive(Debug, Clone, Default)] +struct ProjectPathCandidate { + display_path: String, + count: u32, } pub(crate) fn build_catalog(sessions_dir: &Path) -> Result { @@ -291,14 +301,18 @@ fn scan_session_summary(path: &Path) -> Result { match entry_type { "session_meta" => extract_session_meta(&mut builder, &value), "turn_context" => extract_turn_context(&mut builder, &value), - "response_item" => extract_title_candidate(&mut builder, &value), + "response_item" => { + extract_title_candidate(&mut builder, &value); + if let Some(project) = + project_identity_from_tool_call(&value, builder.cwd.as_deref()) + { + builder.note_project_cwd(project); + } + } _ => {} } - if lines_seen >= INDEX_LINE_LIMIT && builder.has_minimum_fields() { - break; - } - if builder.is_complete() { + if lines_seen >= PROJECT_IDENTITY_LINE_LIMIT && builder.has_minimum_fields() { break; } } @@ -416,15 +430,32 @@ impl SessionSummaryBuilder { self.cwd.is_some() && self.session_id.is_some() } - fn is_complete(&self) -> bool { - self.has_minimum_fields() - && self.meaningful_title.is_some() - && self.started_at_raw.is_some() + fn note_project_cwd(&mut self, display_path: String) { + let key = normalize_project_key(&display_path); + let candidate = self.project_cwds.entry(key).or_default(); + if candidate.display_path.is_empty() { + candidate.display_path = display_path; + } + candidate.count = candidate.count.saturating_add(1); + } + + fn inferred_project_cwd(&self) -> Option { + self.project_cwds + .values() + .max_by(|left, right| { + left.count.cmp(&right.count).then_with(|| { + project_path_depth(&left.display_path) + .cmp(&project_path_depth(&right.display_path)) + }) + }) + .map(|candidate| candidate.display_path.clone()) } fn finish(self) -> Result { let cwd = self - .cwd + .inferred_project_cwd() + .or_else(|| self.cwd.as_deref().and_then(project_identity_from_path)) + .or_else(|| self.cwd.clone()) .ok_or_else(|| anyhow::anyhow!("Missing cwd in {}", self.file_path.display()))?; let session_id = self .session_id @@ -464,6 +495,10 @@ impl SessionSummaryBuilder { } } +fn project_path_depth(path: &str) -> usize { + Path::new(path).components().count() +} + fn extract_message_texts(payload: &serde_json::Map, part_type: &str) -> Vec { let mut texts = Vec::new(); let Some(items) = payload.get("content").and_then(Value::as_array) else { @@ -520,10 +555,6 @@ fn read_i64(value: Option<&Value>) -> Option { }) } -fn normalize_project_key(path: &str) -> String { - path.trim().to_lowercase() -} - fn parse_rfc3339_to_epoch_ms(raw: &str) -> Option { let parsed = DateTime::parse_from_rfc3339(raw).ok()?; Some(parsed.timestamp_millis()) @@ -646,6 +677,60 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn build_catalog_prefers_structured_tool_workdir_git_root() { + let root = make_temp_dir("tool-workdir"); + let sessions = root.join("sessions"); + let project = root.join("rustadmin-fps-diag"); + let project_child = project.join("flutter"); + std::fs::create_dir_all(sessions.join("2026/06/23")).expect("create sessions"); + std::fs::create_dir_all(root.join(".git")).expect("create parent git marker"); + std::fs::create_dir_all(project.join(".git")).expect("create git marker"); + std::fs::create_dir_all(&project_child).expect("create project child"); + let session = sessions.join("2026/06/23/a.jsonl"); + write_session( + &session, + &format!( + "{}\n{}\n{}\n{}\n{}\n", + r#"{"type":"session_meta","payload":{"id":"a","timestamp":"2026-06-23T08:30:22.974Z","cwd":"/outside/non-git","model_provider":"openai"}}"#, + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"work on the project"}]}}"#, + serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({"workdir": root}).to_string() + } + }), + serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({"workdir": project_child}).to_string() + } + }), + serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({"workdir": project_child}).to_string() + } + }) + ), + ); + + let catalog = build_catalog(&sessions).expect("catalog"); + assert_eq!(catalog.projects.len(), 1); + assert_eq!( + catalog.projects[0].display_path, + project.display().to_string() + ); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn load_session_detail_extracts_turns_and_tokens() { let root = make_temp_dir("detail"); diff --git a/src/read/tui.rs b/src/read/tui.rs index 7241053..d005fde 100644 --- a/src/read/tui.rs +++ b/src/read/tui.rs @@ -3,6 +3,7 @@ use crate::read::scan::{ load_session_detail, truncate_single_line, Catalog, ProjectRecord, SessionDetail, SessionSummary, }; +use crate::usage::{format_compact_kmb, format_duration, LocalUsageSnapshot}; use anyhow::Result; use chrono::{DateTime, Local}; use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; @@ -15,6 +16,9 @@ use ratatui::{ }; use std::collections::BTreeMap; use std::path::PathBuf; +use std::time::{Duration, Instant}; + +const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(500); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ViewMode { @@ -22,6 +26,13 @@ enum ViewMode { Sessions, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BrowserClickTarget { + Project(usize), + Parent, + Session(usize), +} + #[derive(Debug, Clone, Copy, Default)] struct UiLayout { project_list_area: Rect, @@ -37,6 +48,7 @@ pub(crate) struct BrowserState { session_details: BTreeMap, error: Option, layout: UiLayout, + last_click: Option<(BrowserClickTarget, Instant)>, } impl BrowserState { @@ -49,18 +61,18 @@ impl BrowserState { }); let mut session_state = ListState::default(); - session_state.select( + session_state.select(Some( if catalog .projects .first() .map(|project| project.sessions.is_empty()) .unwrap_or(true) { - None + 0 } else { - Some(0) + 1 }, - ); + )); Self { catalog, @@ -70,6 +82,7 @@ impl BrowserState { session_details: BTreeMap::new(), error: None, layout: UiLayout::default(), + last_click: None, } } @@ -83,7 +96,7 @@ impl BrowserState { } fn selected_session_index(&self) -> Option { - self.session_state.selected() + self.session_state.selected()?.checked_sub(1) } fn selected_session(&self) -> Option<&SessionSummary> { @@ -111,6 +124,17 @@ impl BrowserState { self.session_details.insert(path, detail); Ok(()) } + + fn register_click(&mut self, target: BrowserClickTarget, now: Instant) -> bool { + let is_double = self.last_click.is_some_and(|(previous, at)| { + previous == target + && now + .checked_duration_since(at) + .is_some_and(|elapsed| elapsed <= DOUBLE_CLICK_WINDOW) + }); + self.last_click = if is_double { None } else { Some((target, now)) }; + is_double + } } pub(crate) fn handle_event(state: &mut BrowserState, event: Event) -> Result { @@ -129,12 +153,9 @@ pub(crate) fn handle_event(state: &mut BrowserState, event: Event) -> Result bool { match code { - KeyCode::Esc | KeyCode::Backspace | KeyCode::Left => { - if state.view == ViewMode::Sessions { - state.view = ViewMode::Projects; - state.error = None; - return true; - } + KeyCode::Esc | KeyCode::Backspace | KeyCode::Left if state.view == ViewMode::Sessions => { + close_selected_project(state); + return true; } KeyCode::Up | KeyCode::Char('k') => { move_selection(state, -1); @@ -162,9 +183,15 @@ fn handle_key_event(state: &mut BrowserState, code: KeyCode) -> bool { } KeyCode::Enter | KeyCode::Right => { if state.view == ViewMode::Projects && state.selected_project().is_some() { - state.view = ViewMode::Sessions; - sync_session_selection(state); - try_load_selected_session_detail(state); + open_selected_project(state); + return true; + } + if state.view == ViewMode::Sessions { + if state.session_state.selected() == Some(0) { + close_selected_project(state); + } else { + try_load_selected_session_detail(state); + } return true; } } @@ -185,17 +212,25 @@ fn handle_mouse_event(state: &mut BrowserState, mouse: MouseEvent) -> bool { true } MouseEventKind::Down(MouseButton::Left) => { - if rect_contains(state.layout.project_list_area, mouse.column, mouse.row) - && click_project_row(state, mouse.row) - { - return true; + let now = Instant::now(); + if rect_contains(state.layout.project_list_area, mouse.column, mouse.row) { + if let Some(index) = click_project_row(state, mouse.row) { + if state.register_click(BrowserClickTarget::Project(index), now) { + open_selected_project(state); + } + return true; + } } - if rect_contains(state.layout.session_list_area, mouse.column, mouse.row) - && click_session_row(state, mouse.row) - { - state.view = ViewMode::Sessions; - try_load_selected_session_detail(state); - return true; + if rect_contains(state.layout.session_list_area, mouse.column, mouse.row) { + if let Some(target) = click_session_row(state, mouse.row) { + let double_click = state.register_click(target, now); + if target == BrowserClickTarget::Parent && double_click { + close_selected_project(state); + } else { + try_load_selected_session_detail(state); + } + return true; + } } false } @@ -217,11 +252,8 @@ fn move_selection(state: &mut BrowserState, delta: isize) { ViewMode::Sessions => { let len = state .selected_project() - .map(|project| project.sessions.len()) - .unwrap_or(0); - if len == 0 { - return; - } + .map(|project| project.sessions.len().saturating_add(1)) + .unwrap_or(1); let next = advance_index(state.session_state.selected(), len, delta); state.session_state.select(Some(next)); try_load_selected_session_detail(state); @@ -243,11 +275,8 @@ fn jump_to_edge(state: &mut BrowserState, first: bool) { ViewMode::Sessions => { let len = state .selected_project() - .map(|project| project.sessions.len()) - .unwrap_or(0); - if len == 0 { - return; - } + .map(|project| project.sessions.len().saturating_add(1)) + .unwrap_or(1); let index = if first { 0 } else { len.saturating_sub(1) }; state.session_state.select(Some(index)); try_load_selected_session_detail(state); @@ -258,17 +287,38 @@ fn jump_to_edge(state: &mut BrowserState, first: bool) { fn sync_session_selection(state: &mut BrowserState) { let len = state .selected_project() - .map(|project| project.sessions.len()) - .unwrap_or(0); - if len == 0 { - state.session_state.select(None); - return; - } - let selected = state.session_state.selected().unwrap_or(0); + .map(|project| project.sessions.len().saturating_add(1)) + .unwrap_or(1); + let selected = state.session_state.selected().unwrap_or(1); let clamped = selected.min(len.saturating_sub(1)); state.session_state.select(Some(clamped)); } +fn open_selected_project(state: &mut BrowserState) { + if state.selected_project().is_none() { + return; + } + state.view = ViewMode::Sessions; + state.session_state.select(Some( + if state + .selected_project() + .is_some_and(|project| project.sessions.is_empty()) + { + 0 + } else { + 1 + }, + )); + state.last_click = None; + try_load_selected_session_detail(state); +} + +fn close_selected_project(state: &mut BrowserState) { + state.view = ViewMode::Projects; + state.error = None; + state.last_click = None; +} + fn try_load_selected_session_detail(state: &mut BrowserState) { if state.selected_session().is_none() { state.error = None; @@ -281,6 +331,9 @@ fn try_load_selected_session_detail(state: &mut BrowserState) { } fn advance_index(current: Option, len: usize, delta: isize) -> usize { + if len == 0 { + return 0; + } let current = current.unwrap_or(0); let current = isize::try_from(current).unwrap_or(0); let len = isize::try_from(len).unwrap_or(0); @@ -288,45 +341,51 @@ fn advance_index(current: Option, len: usize, delta: isize) -> usize { usize::try_from(next).unwrap_or(0) } -fn click_project_row(state: &mut BrowserState, row: u16) -> bool { +fn click_project_row(state: &mut BrowserState, row: u16) -> Option { let content = inner_rect(state.layout.project_list_area); if row < content.y || row >= content.y.saturating_add(content.height) { - return false; + return None; } let current_offset = state.project_state.offset(); let row_offset = usize::from(row.saturating_sub(content.y)); let index = current_offset.saturating_add(row_offset); if index >= state.catalog.projects.len() { - return false; + return None; } state.project_state.select(Some(index)); sync_session_selection(state); - true + Some(index) } -fn click_session_row(state: &mut BrowserState, row: u16) -> bool { +fn click_session_row(state: &mut BrowserState, row: u16) -> Option { let content = inner_rect(state.layout.session_list_area); if row < content.y || row >= content.y.saturating_add(content.height) { - return false; + return None; } let current_offset = state.session_state.offset(); let row_offset = usize::from(row.saturating_sub(content.y)); let index = current_offset.saturating_add(row_offset); let len = state .selected_project() - .map(|project| project.sessions.len()) - .unwrap_or(0); + .map(|project| project.sessions.len().saturating_add(1)) + .unwrap_or(1); if index >= len { - return false; + return None; } state.session_state.select(Some(index)); - true + if index == 0 { + Some(BrowserClickTarget::Parent) + } else { + Some(BrowserClickTarget::Session(index - 1)) + } } pub(crate) fn render( frame: &mut Frame<'_>, area: Rect, state: &mut BrowserState, + usage: Option<&LocalUsageSnapshot>, + usage_error: Option<&str>, formatter: DisplayFormatter<'_>, ) { let chunks = Layout::default() @@ -340,7 +399,9 @@ pub(crate) fn render( render_header(frame, chunks[0], state, formatter); state.layout = match state.view { - ViewMode::Projects => render_projects_view(frame, chunks[1], state, formatter), + ViewMode::Projects => { + render_projects_view(frame, chunks[1], state, usage, usage_error, formatter) + } ViewMode::Sessions => render_sessions_view(frame, chunks[1], state, formatter), }; render_footer(frame, chunks[2], state, formatter); @@ -353,10 +414,20 @@ fn render_header( formatter: DisplayFormatter<'_>, ) { let line_area = header_line_area(area); + let controls = history_style_controls_area(area); + let content_area = Rect { + width: line_area.width.saturating_sub(controls.width), + ..line_area + }; + let show_summary = content_area.width >= 62; let row = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(32), Constraint::Length(30)]) - .split(line_area); + .constraints(if show_summary { + [Constraint::Min(32), Constraint::Length(30)] + } else { + [Constraint::Min(0), Constraint::Length(0)] + }) + .split(content_area); let scope = format!( "SESSION_HISTORY {}", @@ -376,13 +447,15 @@ fn render_header( formatter.format_usize(state.catalog.files_scanned), formatter.format_usize(state.catalog.files_skipped) ); - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - summary, - Style::default().fg(Color::Gray), - ))), - row[1], - ); + if show_summary { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + summary, + Style::default().fg(Color::Gray), + ))), + row[1], + ); + } } fn header_line_area(area: Rect) -> Rect { @@ -394,10 +467,26 @@ fn header_line_area(area: Rect) -> Rect { } } +pub(crate) fn history_style_controls_area(area: Rect) -> Rect { + const WIDTH: u16 = 28; + let line = header_line_area(area); + if line.width < WIDTH { + return Rect::default(); + } + Rect { + x: line.x.saturating_add(line.width - WIDTH), + y: line.y, + width: WIDTH, + height: 1, + } +} + fn render_projects_view( frame: &mut Frame<'_>, area: Rect, state: &mut BrowserState, + usage: Option<&LocalUsageSnapshot>, + usage_error: Option<&str>, formatter: DisplayFormatter<'_>, ) -> UiLayout { let columns = Layout::default() @@ -428,7 +517,8 @@ fn render_projects_view( .highlight_symbol(">> "); frame.render_stateful_widget(list, columns[0], &mut state.project_state); - let detail_text = render_project_detail(state.selected_project(), formatter); + let detail_text = + render_project_detail(state.selected_project(), usage, usage_error, formatter); frame.render_widget( Paragraph::new(detail_text) .block( @@ -460,17 +550,15 @@ fn render_sessions_view( let project = state.selected_project(); let items = project .map(|project| { - project - .sessions - .iter() - .map(|session| { + std::iter::once(ListItem::new(Line::from(".."))) + .chain(project.sessions.iter().map(|session| { let label = format!( "{} {}", session_started_label(session, formatter), truncate_single_line(&session.title, 64) ); ListItem::new(Line::from(label)) - }) + })) .collect::>() }) .unwrap_or_default(); @@ -514,6 +602,8 @@ fn render_sessions_view( fn render_project_detail( project: Option<&ProjectRecord>, + usage: Option<&LocalUsageSnapshot>, + usage_error: Option<&str>, formatter: DisplayFormatter<'_>, ) -> Text<'static> { let Some(project) = project else { @@ -526,6 +616,51 @@ fn render_project_detail( .map(|session| session_started_label(session, formatter)) .unwrap_or_else(|| "--".to_string()); + let usage_summary = + usage.and_then(|snapshot| snapshot.project_usage_for_path(&project.display_path)); + let expected_files = project.sessions.len(); + let indexed_files = usage_summary + .map(|summary| summary.indexed_files) + .unwrap_or(0); + let scan_complete = usage.is_some_and(|snapshot| snapshot.scan_pending_files == 0); + let usage_ready = + usage_error.is_none() && usage_summary.is_some() && indexed_files >= expected_files; + let project_scan = if usage_error.is_some() { + "ERROR".to_string() + } else if usage_ready { + "READY".to_string() + } else if scan_complete { + "NO DATA".to_string() + } else { + format!( + "SCANNING {}/{}", + formatter.format_usize(indexed_files), + formatter.format_usize(expected_files) + ) + }; + + let (consumed_tokens, activity) = if usage_ready { + let summary = usage_summary.expect("usage summary checked above"); + let non_cached = summary + .total_tokens + .saturating_sub(summary.cached_input_tokens) + .max(0); + ( + format!( + "{} total / {} non-cached", + format_project_count(summary.total_tokens, formatter), + format_project_count(non_cached, formatter) + ), + format!( + "{} runs / {}", + formatter.format_count(summary.agent_runs), + format_duration(summary.agent_time_ms) + ), + ) + } else { + ("--".to_string(), "--".to_string()) + }; + let mut lines = vec![ Line::from(vec![ Span::styled("PATH", Style::default().fg(Color::Gray)), @@ -546,6 +681,25 @@ fn render_project_detail( Span::raw(latest), ]), Line::from(""), + Line::from(vec![ + Span::styled("CURRENT_CONSUMED_TOKENS", Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled( + consumed_tokens, + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled("PROJECT_SCAN", Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::raw(project_scan), + ]), + Line::from(vec![ + Span::styled("ACTIVITY", Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::raw(activity), + ]), + Line::from(""), Line::from(Span::styled( "RECENT", Style::default() @@ -565,6 +719,13 @@ fn render_project_detail( Text::from(lines) } +fn format_project_count(value: i64, formatter: DisplayFormatter<'_>) -> String { + match formatter.style() { + DisplayStyle::SystemCompact => format_compact_kmb(value.max(0) as u64, 16, formatter), + DisplayStyle::Classic | DisplayStyle::SystemFull => formatter.format_count(value), + } +} + fn render_session_detail( session: Option<&SessionSummary>, detail: Option<&SessionDetail>, @@ -740,10 +901,10 @@ fn render_footer( ) { let base = match state.view { ViewMode::Projects => { - "Projects: up/down, mouse wheel, enter/right open, s/F2 switch, r/F5 rescan, q quit" + "Projects: up/down or wheel, double-click/enter open, s/F2 switch, r/F5 rescan, q quit" } ViewMode::Sessions => { - "Sessions: up/down, mouse wheel, backspace/left/esc back, s/F2 switch, r/F5 rescan, q quit" + "Sessions: up/down or wheel, double-click .. / backspace / left / esc back, q quit" } }; let error = state @@ -796,8 +957,14 @@ fn rect_contains(area: Rect, column: u16, row: u16) -> bool { #[cfg(test)] mod tests { - use super::header_line_area; + use super::{ + header_line_area, history_style_controls_area, BrowserClickTarget, BrowserState, + DOUBLE_CLICK_WINDOW, + }; + use crate::read::scan::Catalog; use ratatui::layout::Rect; + use std::path::PathBuf; + use std::time::{Duration, Instant}; #[test] fn header_line_has_one_blank_row_above_and_below() { @@ -806,4 +973,34 @@ mod tests { Rect::new(2, 2, 100, 1) ); } + + #[test] + fn history_style_controls_use_right_side_of_header_line() { + assert_eq!( + history_style_controls_area(Rect::new(2, 1, 100, 3)), + Rect::new(74, 2, 28, 1) + ); + assert_eq!( + history_style_controls_area(Rect::new(2, 1, 20, 3)), + Rect::default() + ); + } + + #[test] + fn identical_clicks_within_window_are_double_clicks() { + let mut state = BrowserState::new(Catalog { + sessions_dir: PathBuf::from("/tmp/sessions"), + projects: Vec::new(), + files_scanned: 0, + files_skipped: 0, + }); + let now = Instant::now(); + assert!(!state.register_click(BrowserClickTarget::Project(2), now)); + assert!(state.register_click(BrowserClickTarget::Project(2), now + DOUBLE_CLICK_WINDOW)); + assert!(!state.register_click(BrowserClickTarget::Parent, now)); + assert!(!state.register_click( + BrowserClickTarget::Parent, + now + DOUBLE_CLICK_WINDOW + Duration::from_millis(1) + )); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f3189e8..cbe0c9b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -180,7 +180,15 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { ActiveScreen::Read => { let system_locale = state.system_locale.clone(); let formatter = DisplayFormatter::new(state.display_style, &system_locale); - crate::read::tui::render(frame, inner, &mut state.read_browser, formatter); + crate::read::tui::render( + frame, + inner, + &mut state.read_browser, + state.usage.as_ref(), + state.usage_error.as_deref(), + formatter, + ); + render_history_style_controls(frame, inner, state); } } @@ -2711,6 +2719,38 @@ fn control_group_label(label: &'static str) -> Span<'static> { ) } +fn render_history_style_controls(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + let area = crate::read::tui::history_style_controls_area(area); + if area.width == 0 { + return; + } + let segments = [ + (" STYLE ", None), + ( + " CLASS ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::Classic)), + ), + ( + " SCOMP ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemCompact)), + ), + ( + " SFULL ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemFull)), + ), + ]; + let spans = vec![ + control_group_label("STYLE"), + pill("CLASS", state.display_style == DisplayStyle::Classic), + pill("SCOMP", state.display_style == DisplayStyle::SystemCompact), + pill("SFULL", state.display_style == DisplayStyle::SystemFull), + ]; + frame.render_widget(Paragraph::new(Line::from(spans)), area); + state + .ui_hit_targets + .extend(right_aligned_targets(area, &segments)); +} + #[derive(Debug)] struct CardSpec { lines: Vec, diff --git a/src/usage/mod.rs b/src/usage/mod.rs index 6e5f5d7..a6559c5 100644 --- a/src/usage/mod.rs +++ b/src/usage/mod.rs @@ -18,7 +18,8 @@ const DEFAULT_MAX_SESSION_FILES_SCANNED: usize = 10_000; const DEFAULT_MAX_JSONL_LINE_BYTES: usize = 512 * 1024; const DEFAULT_SCAN_TIME_BUDGET_MS: u64 = 1500; const MAX_DISTINCT_MODELS: usize = 5_000; -const SCAN_CACHE_DB_SCHEMA_VERSION: i64 = 6; +const SCAN_CACHE_DB_SCHEMA_VERSION: i64 = 9; +pub(crate) const PROJECT_IDENTITY_LINE_LIMIT: usize = 128; const FORK_REPLAY_END_GAP_MS: i64 = 1_000; const FORK_REPLAY_NO_TOKEN_GRACE_MS: i64 = 2_000; pub const DEFAULT_SCAN_CACHE_MAX_ENTRIES: usize = 50_000; @@ -222,6 +223,16 @@ pub struct ProjectActivity { pub agent_runs: i64, } +#[derive(Debug, Clone)] +pub struct ProjectUsageSummary { + pub display_path: String, + pub total_tokens: i64, + pub cached_input_tokens: i64, + pub agent_time_ms: i64, + pub agent_runs: i64, + pub indexed_files: usize, +} + #[derive(Debug, Clone)] pub struct LocalUsageSnapshot { pub days: Vec, @@ -232,6 +243,7 @@ pub struct LocalUsageSnapshot { pub utc_top_models: Vec, pub activity_first_weekday: Weekday, pub project_activity: Vec, + pub project_usage: Vec, // Number of session files that were identified as belonging to the selected workspace filter. // When no workspace filter is used, this is 0. pub matched_session_files: u32, @@ -254,6 +266,13 @@ pub struct UsageTotalsView { } impl LocalUsageSnapshot { + pub fn project_usage_for_path(&self, path: &str) -> Option<&ProjectUsageSummary> { + let key = normalize_project_key(path); + self.project_usage + .iter() + .find(|project| normalize_project_key(&project.display_path) == key) + } + pub fn days_for_zone(&self, zone: UsageZone) -> &[UsageDay] { match zone { UsageZone::Local => &self.days, @@ -450,6 +469,12 @@ struct ParserState { fork_baseline: Option, #[serde(default)] fork_live_started: bool, + #[serde(default)] + project_cwd: Option, + #[serde(default)] + project_cwd_counts: HashMap, + #[serde(default)] + project_identity_lines_seen: u32, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] @@ -522,6 +547,16 @@ struct ProjectActivityBuilder { daily: HashMap, } +#[derive(Debug, Default)] +struct ProjectUsageBuilder { + display_path: String, + total_tokens: i64, + cached_input_tokens: i64, + agent_time_ms: i64, + agent_runs: i64, + indexed_files: usize, +} + fn default_true() -> bool { true } @@ -603,6 +638,7 @@ pub fn compute_snapshot( activity_first_weekday, activity_day_keys, project_activity, + Vec::new(), 0, 0, 0, @@ -903,6 +939,9 @@ pub fn compute_snapshot( &mut project_activity, &mut matched_session_files, ); + scan_cache_store + .entries + .insert(candidate_paths[idx].clone(), entry); } } @@ -949,6 +988,8 @@ pub fn compute_snapshot( let chart_day_keys = make_complete_chart_day_keys(&daily, UsageZone::Local, &summary_day_keys); let utc_chart_day_keys = make_complete_chart_day_keys(&utc_daily, UsageZone::Utc, &utc_summary_day_keys); + let project_usage = + build_project_usage_summaries(&candidates, &candidate_paths, &scan_cache_store); Ok(build_snapshot( chart_day_keys, @@ -961,6 +1002,7 @@ pub fn compute_snapshot( activity_first_weekday, activity_day_keys, project_activity, + project_usage, scan_total_files, scan_indexed_files, scan_pending_files, @@ -1238,6 +1280,7 @@ fn build_snapshot( activity_first_weekday: Weekday, activity_day_keys: Vec, project_activity: HashMap, + project_usage: Vec, scan_total_files: usize, scan_indexed_files: usize, scan_pending_files: usize, @@ -1256,6 +1299,7 @@ fn build_snapshot( utc_top_models, activity_first_weekday, project_activity: build_project_activity(activity_day_keys, project_activity), + project_usage, matched_session_files, scan_total_files, scan_indexed_files, @@ -1340,6 +1384,74 @@ fn build_zone_snapshot( ) } +fn build_project_usage_summaries( + candidates: &[SessionFileCandidate], + candidate_paths: &[String], + cache: &ScanCacheStore, +) -> Vec { + let mut projects: HashMap = HashMap::new(); + + for (index, candidate) in candidates.iter().enumerate() { + let Some(path) = candidate_paths.get(index) else { + continue; + }; + let Some(entry) = cache.entries.get(path) else { + continue; + }; + if !cache_entry_matches_candidate(entry, candidate) { + continue; + } + let Some(cwd) = entry + .session_cwd + .as_deref() + .filter(|cwd| !cwd.trim().is_empty()) + else { + continue; + }; + let key = normalize_project_key(cwd); + if key.is_empty() { + continue; + } + + let project = projects.entry(key).or_default(); + if project.display_path.is_empty() + || cwd.len() < project.display_path.len() + || (cwd.len() == project.display_path.len() && cwd < project.display_path.as_str()) + { + project.display_path = cwd.to_string(); + } + project.indexed_files = project.indexed_files.saturating_add(1); + + for (cache_key, totals) in &entry.daily { + let Some((UsageZone::Local, _)) = split_cache_day_key(cache_key) else { + continue; + }; + project.total_tokens = project + .total_tokens + .saturating_add(totals.input.saturating_add(totals.output)); + project.cached_input_tokens = project + .cached_input_tokens + .saturating_add(totals.cached.min(totals.input)); + project.agent_time_ms = project.agent_time_ms.saturating_add(totals.agent_ms); + project.agent_runs = project.agent_runs.saturating_add(totals.agent_runs); + } + } + + let mut out = projects + .into_values() + .map(|project| ProjectUsageSummary { + display_path: project.display_path, + total_tokens: project.total_tokens, + cached_input_tokens: project.cached_input_tokens, + agent_time_ms: project.agent_time_ms, + agent_runs: project.agent_runs, + indexed_files: project.indexed_files, + }) + .collect::>(); + out.sort_by(|left, right| left.display_path.cmp(&right.display_path)); + out +} + fn build_project_activity( day_keys: Vec, projects: HashMap, @@ -1638,6 +1750,8 @@ fn parse_file_summary( break; } file_offset = file_offset.saturating_add(bytes_read as u64); + parser_state.project_identity_lines_seen = + parser_state.project_identity_lines_seen.saturating_add(1); if line.len() > max_jsonl_line_bytes { continue; } @@ -1654,6 +1768,17 @@ fn parse_file_summary( if (entry_type == "session_meta" || entry_type == "turn_context") && session_cwd.is_none() { session_cwd = extract_cwd(&value); } + if entry_type == "response_item" + && usize::try_from(parser_state.project_identity_lines_seen).unwrap_or(usize::MAX) + <= PROJECT_IDENTITY_LINE_LIMIT + { + if let Some(project) = project_identity_from_tool_call(&value, session_cwd.as_deref()) { + let count = parser_state.project_cwd_counts.entry(project).or_default(); + *count = count.saturating_add(1); + parser_state.project_cwd = + preferred_project_identity(&parser_state.project_cwd_counts); + } + } let started_fork_replay = if entry_type == "session_meta" { maybe_start_fork_replay(&value, &mut first_session_meta_seen, &mut fork_replay) @@ -1883,8 +2008,13 @@ fn parse_file_summary( parser_state.fork_replay = fork_replay; parser_state.fork_live_started = fork_live_started; + let effective_cwd = parser_state + .project_cwd + .clone() + .or_else(|| session_cwd.as_deref().and_then(project_identity_from_path)) + .or(session_cwd); Ok(FileScanSummary { - session_cwd, + session_cwd: effective_cwd, parser_state, file_offset: file_offset.min(file_len), fully_parsed: fully_parsed && file_offset >= file_len, @@ -1992,6 +2122,19 @@ fn open_or_init_scan_cache_db(path: &Path) -> Result { // response after the copied prefix contributes to run/activity data. invalidate_forked_session_cache_rows(&conn, path)?; } + if schema_version < 9 { + // v9 identifies the effective project from the dominant structured + // tool workdir before falling back to the session cwd. + // Existing rows must be replayed because parser_state_json did not yet + // retain that identity. + conn.execute("DELETE FROM file_cache;", []) + .with_context(|| { + format!( + "Unable to rebuild v9 project identities in {}", + path.display() + ) + })?; + } if schema_version < SCAN_CACHE_DB_SCHEMA_VERSION { conn.execute( "UPDATE cache_meta SET value = ?1 WHERE key = 'schema_version';", @@ -2831,8 +2974,121 @@ fn path_matches_workspace(cwd: &str, workspace_path: &Path) -> bool { cwd_path == workspace_path || cwd_path.starts_with(workspace_path) } -fn normalize_project_key(path: &str) -> String { - path.trim().to_lowercase() +pub(crate) fn normalize_project_key(path: &str) -> String { + let mut normalized = normalize_wsl_unc_path(path) + .unwrap_or_else(|| path.trim().replace('\\', "/")) + .to_lowercase(); + while normalized.len() > 1 && normalized.ends_with('/') { + normalized.pop(); + } + normalized +} + +pub(crate) fn project_identity_from_tool_call( + value: &Value, + session_cwd: Option<&str>, +) -> Option { + if value.get("type").and_then(Value::as_str) != Some("response_item") { + return None; + } + let payload = value.get("payload")?.as_object()?; + if payload.get("type").and_then(Value::as_str) != Some("function_call") { + return None; + } + + let arguments = payload.get("arguments")?; + let parsed_arguments; + let arguments = if let Some(raw) = arguments.as_str() { + parsed_arguments = serde_json::from_str::(raw).ok()?; + parsed_arguments.as_object()? + } else { + arguments.as_object()? + }; + let raw = ["workdir", "cwd", "working_directory"] + .into_iter() + .find_map(|key| arguments.get(key).and_then(Value::as_str))?; + if let Some(identity) = project_identity_from_path(raw) { + return Some(identity); + } + + let base = session_cwd.and_then(normalize_cross_platform_path)?; + let relative = Path::new(raw); + if relative.is_absolute() { + return None; + } + project_identity_from_path(&base.join(relative).display().to_string()) +} + +pub(crate) fn project_identity_from_path(raw: &str) -> Option { + if raw.is_empty() || raw.len() > 4096 || raw.chars().any(char::is_control) { + return None; + } + let normalized = normalize_cross_platform_path(raw)?; + let path = normalized.as_path(); + if !path.is_absolute() { + return None; + } + + let mut current = path; + loop { + if current.join(".git").exists() { + let canonical = + std::fs::canonicalize(current).unwrap_or_else(|_| current.to_path_buf()); + return Some(canonical.display().to_string()); + } + current = current.parent()?; + } +} + +fn preferred_project_identity(counts: &HashMap) -> Option { + counts + .iter() + .max_by(|(left_path, left_count), (right_path, right_count)| { + left_count.cmp(right_count).then_with(|| { + Path::new(left_path) + .components() + .count() + .cmp(&Path::new(right_path).components().count()) + }) + }) + .map(|(path, _)| path.clone()) +} + +fn normalize_cross_platform_path(path: &str) -> Option { + let normalized = normalize_wsl_unc_path(path).unwrap_or_else(|| path.trim().replace('\\', "/")); + if normalized.is_empty() { + return None; + } + + #[cfg(unix)] + { + let bytes = normalized.as_bytes(); + if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'/' + { + let drive = char::from(bytes[0]).to_ascii_lowercase(); + return Some(PathBuf::from(format!("/mnt/{drive}/{}", &normalized[3..]))); + } + } + + Some(PathBuf::from(normalized)) +} + +fn normalize_wsl_unc_path(path: &str) -> Option { + let normalized = path.trim().replace('\\', "/"); + let lower = normalized.to_lowercase(); + let prefix_len = if lower.starts_with("//wsl.localhost/") { + "//wsl.localhost/".len() + } else if lower.starts_with("//wsl$/") { + "//wsl$/".len() + } else { + return None; + }; + let rest = normalized.get(prefix_len..)?; + let (_, linux_path) = rest.split_once('/')?; + Some(format!("/{linux_path}")) } fn make_day_keys_for_zone(days: u32, zone: UsageZone) -> Vec { @@ -3671,11 +3927,11 @@ mod tests { let newer_ms = now_ms - Duration::days(1).num_milliseconds(); let photonia = sessions_root.join("photonia.jsonl"); - append_session_meta_line(&photonia, older_ms, "/tmp/Photonia"); + append_session_meta_line(&photonia, older_ms, "/outside/Photonia"); append_total_token_line(&photonia, older_ms + 100, 100, 0, 20); let sfm = sessions_root.join("sfm.jsonl"); - append_session_meta_line(&sfm, newer_ms, "/tmp/SFM"); + append_session_meta_line(&sfm, newer_ms, "/outside/SFM"); append_total_token_line(&sfm, newer_ms + 100, 200, 150, 50); let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) @@ -3686,16 +3942,59 @@ mod tests { snapshot.project_activity[0].days.len(), ACTIVITY_TIMELINE_DAYS ); - assert_eq!(snapshot.project_activity[0].display_path, "/tmp/SFM"); + assert_eq!(snapshot.project_activity[0].display_path, "/outside/SFM"); assert_eq!(snapshot.project_activity[0].total_tokens, 250); assert_eq!(snapshot.project_activity[0].cached_input_tokens, 150); - assert_eq!(snapshot.project_activity[1].display_path, "/tmp/Photonia"); + assert_eq!( + snapshot.project_activity[1].display_path, + "/outside/Photonia" + ); assert_eq!(snapshot.project_activity[1].total_tokens, 120); assert_eq!(snapshot.project_activity[1].cached_input_tokens, 0); let _ = std::fs::remove_dir_all(root); } + #[test] + fn compute_snapshot_attributes_project_usage_to_structured_tool_workdir() { + let root = make_temp_dir("project-tool-workdir"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + let project = root.join("rustadmin-fps-diag"); + let project_child = project.join("flutter"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + std::fs::create_dir_all(project.join(".git")).expect("create git marker"); + std::fs::create_dir_all(&project_child).expect("create project child"); + + let now_ms = Utc::now().timestamp_millis(); + let session = sessions_root.join("session.jsonl"); + append_session_meta_line(&session, now_ms, "/outside/non-git"); + append_json_line( + &session, + serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({"workdir": project_child}).to_string() + } + }), + ); + append_total_token_line(&session, now_ms + 100, 100, 25, 20); + + let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) + .expect("snapshot"); + let summary = snapshot + .project_usage_for_path(&project.display().to_string()) + .expect("project usage"); + assert_eq!(summary.display_path, project.display().to_string()); + assert_eq!(summary.total_tokens, 120); + assert_eq!(summary.cached_input_tokens, 25); + assert_eq!(summary.indexed_files, 1); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn compute_snapshot_keeps_cached_totals_for_unplanned_unchanged_files() { let root = make_temp_dir("cache-unplanned"); @@ -4217,8 +4516,8 @@ mod tests { } #[test] - fn open_or_init_scan_cache_db_v6_reparses_only_v5_fork_rows() { - let root = make_temp_dir("cache-migrate-v6"); + fn open_or_init_scan_cache_db_v9_reparses_v8_rows_for_project_identity() { + let root = make_temp_dir("cache-migrate-v9"); let sessions_root = root.join("sessions"); std::fs::create_dir_all(&sessions_root).expect("create sessions root"); let keep_path = sessions_root.join("keep.jsonl"); @@ -4228,14 +4527,14 @@ mod tests { write_forked_replay_file(&forked_path, now_ms); let db_path = root.join("comon.db"); - let conn = Connection::open(&db_path).expect("open v5 db"); + let conn = Connection::open(&db_path).expect("open v8 db"); conn.execute_batch( " CREATE TABLE cache_meta ( key TEXT PRIMARY KEY, value INTEGER NOT NULL ); - INSERT INTO cache_meta(key, value) VALUES('schema_version', 5); + INSERT INTO cache_meta(key, value) VALUES('schema_version', 8); CREATE TABLE file_cache ( file_path TEXT PRIMARY KEY, file_size INTEGER NOT NULL, @@ -4250,7 +4549,7 @@ mod tests { ); ", ) - .expect("create v5 schema"); + .expect("create v8 schema"); for path in [&keep_path, &forked_path] { conn.execute( " @@ -4267,16 +4566,73 @@ mod tests { let db = open_or_init_scan_cache_db(&db_path).expect("open and migrate"); let (store, _) = load_scan_cache_store(&db).expect("load migrated cache"); - assert!(store - .entries - .contains_key(&keep_path.to_string_lossy().to_string())); - assert!(!store - .entries - .contains_key(&forked_path.to_string_lossy().to_string())); + assert!(store.entries.is_empty()); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn project_keys_unify_wsl_unc_and_linux_paths() { + assert_eq!( + normalize_project_key(r"\\wsl.localhost\Ubuntu\home\w0w\rustadmin-fps-diag\"), + normalize_project_key("/home/w0w/rustadmin-fps-diag") + ); + assert_eq!( + normalize_project_key(r"\\wsl$\Ubuntu\home\w0w\rustadmin-fps-diag"), + "/home/w0w/rustadmin-fps-diag" + ); + } + + #[test] + fn structured_relative_workdir_resolves_from_session_cwd() { + let root = make_temp_dir("relative-project-workdir"); + let launcher = root.join("launcher"); + let project = root.join("project"); + std::fs::create_dir_all(&launcher).expect("create launcher"); + std::fs::create_dir_all(project.join(".git")).expect("create git marker"); + std::fs::create_dir_all(project.join("flutter")).expect("create child"); + let value = serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "arguments": serde_json::json!({"workdir": "../project/flutter"}).to_string() + } + }); + + assert_eq!( + project_identity_from_tool_call(&value, Some(&launcher.display().to_string())), + Some( + std::fs::canonicalize(&project) + .expect("canonical project") + .display() + .to_string() + ) + ); let _ = std::fs::remove_dir_all(root); } + #[test] + fn dominant_project_identity_wins_over_earlier_parent_workdir() { + let counts = HashMap::from([ + ("/home/w0w".to_string(), 3), + ("/home/w0w/rustadmin-fps-diag".to_string(), 21), + ]); + assert_eq!( + preferred_project_identity(&counts), + Some("/home/w0w/rustadmin-fps-diag".to_string()) + ); + } + + #[cfg(unix)] + #[test] + fn windows_drive_path_maps_to_wsl_mount() { + assert_eq!( + normalize_cross_platform_path(r"C:\Users\w0w\project"), + Some(PathBuf::from("/mnt/c/Users/w0w/project")) + ); + } + #[test] fn open_or_init_scan_cache_db_migrates_v1_schema_and_clears_stale_rows() { let root = make_temp_dir("cache-migrate"); From a23fb2b3705a48ac10b1453fd7fc9a39d4a53595 Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:30:49 +0300 Subject: [PATCH 2/3] Fix history header control spacing --- src/read/tui.rs | 22 ++++++++++++---------- src/ui/mod.rs | 2 ++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/read/tui.rs b/src/read/tui.rs index d005fde..73fd574 100644 --- a/src/read/tui.rs +++ b/src/read/tui.rs @@ -17,6 +17,7 @@ use ratatui::{ use std::collections::BTreeMap; use std::path::PathBuf; use std::time::{Duration, Instant}; +use unicode_width::UnicodeWidthStr; const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(500); @@ -419,11 +420,18 @@ fn render_header( width: line_area.width.saturating_sub(controls.width), ..line_area }; - let show_summary = content_area.width >= 62; + let summary = format!( + "{} projects {} files {} skipped", + formatter.format_usize(state.catalog.projects.len()), + formatter.format_usize(state.catalog.files_scanned), + formatter.format_usize(state.catalog.files_skipped) + ); + let summary_width = u16::try_from(UnicodeWidthStr::width(summary.as_str())).unwrap_or(u16::MAX); + let show_summary = content_area.width >= 32u16.saturating_add(summary_width); let row = Layout::default() .direction(Direction::Horizontal) .constraints(if show_summary { - [Constraint::Min(32), Constraint::Length(30)] + [Constraint::Min(32), Constraint::Length(summary_width)] } else { [Constraint::Min(0), Constraint::Length(0)] }) @@ -441,12 +449,6 @@ fn render_header( row[0], ); - let summary = format!( - "{} projects {} files {} skipped", - formatter.format_usize(state.catalog.projects.len()), - formatter.format_usize(state.catalog.files_scanned), - formatter.format_usize(state.catalog.files_skipped) - ); if show_summary { frame.render_widget( Paragraph::new(Line::from(Span::styled( @@ -468,7 +470,7 @@ fn header_line_area(area: Rect) -> Rect { } pub(crate) fn history_style_controls_area(area: Rect) -> Rect { - const WIDTH: u16 = 28; + const WIDTH: u16 = 30; let line = header_line_area(area); if line.width < WIDTH { return Rect::default(); @@ -978,7 +980,7 @@ mod tests { fn history_style_controls_use_right_side_of_header_line() { assert_eq!( history_style_controls_area(Rect::new(2, 1, 100, 3)), - Rect::new(74, 2, 28, 1) + Rect::new(72, 2, 30, 1) ); assert_eq!( history_style_controls_area(Rect::new(2, 1, 20, 3)), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index cbe0c9b..0b46924 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2725,6 +2725,7 @@ fn render_history_style_controls(frame: &mut Frame<'_>, area: Rect, state: &mut return; } let segments = [ + (" ", None), (" STYLE ", None), ( " CLASS ", @@ -2740,6 +2741,7 @@ fn render_history_style_controls(frame: &mut Frame<'_>, area: Rect, state: &mut ), ]; let spans = vec![ + Span::raw(" "), control_group_label("STYLE"), pill("CLASS", state.display_style == DisplayStyle::Classic), pill("SCOMP", state.display_style == DisplayStyle::SystemCompact), From 8c58549ff1976adb19c6a742710ee9e2b015e4c3 Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:20:28 +0300 Subject: [PATCH 3/3] Support multi-project session attribution --- src/read/scan.rs | 246 +++++++++++-- src/read/tui.rs | 57 ++- src/usage/mod.rs | 898 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 1095 insertions(+), 106 deletions(-) diff --git a/src/read/scan.rs b/src/read/scan.rs index c5bef13..b57f3cc 100644 --- a/src/read/scan.rs +++ b/src/read/scan.rs @@ -1,11 +1,11 @@ use crate::usage::{ - normalize_project_key, project_identity_from_path, project_identity_from_tool_call, - PROJECT_IDENTITY_LINE_LIMIT, + normalize_project_key, project_identities_from_structured_event, project_identity_from_path, + ProjectUsageSummary, PROJECT_IDENTITY_LINE_LIMIT, }; use anyhow::{Context, Result}; use chrono::{DateTime, Local, Utc}; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -42,6 +42,57 @@ pub(crate) struct SessionSummary { pub(crate) repo_url: Option, pub(crate) model_provider: Option, pub(crate) model: Option, + project_paths: Vec, +} + +impl Catalog { + pub(crate) fn reconcile_project_usage(&mut self, usage: &[ProjectUsageSummary]) { + let mut summaries: HashMap = HashMap::new(); + for project in &self.projects { + for session in &project.sessions { + summaries + .entry(session.file_path.clone()) + .or_insert_with(|| session.clone()); + } + } + + let indexed_paths = usage + .iter() + .flat_map(|project| project.session_files.iter().cloned()) + .collect::>(); + let mut grouped: BTreeMap = BTreeMap::new(); + + for project in &self.projects { + for session in &project.sessions { + if indexed_paths.contains(&session.file_path) { + continue; + } + grouped + .entry(normalize_project_key(&project.display_path)) + .or_default() + .sessions + .push(session.clone()); + } + } + + for project in usage { + let key = normalize_project_key(&project.display_path); + if key.is_empty() { + continue; + } + let builder = grouped.entry(key).or_default(); + for file_path in &project.session_files { + let Some(summary) = summaries.get(file_path) else { + continue; + }; + let mut linked = summary.clone(); + linked.cwd = project.display_path.clone(); + builder.sessions.push(linked); + } + } + + self.projects = finish_project_builders(grouped); + } } #[derive(Debug, Clone, Default)] @@ -108,8 +159,12 @@ pub(crate) fn build_catalog(sessions_dir: &Path) -> Result { match scan_session_summary(&path) { Ok(summary) => { files_scanned += 1; - let key = normalize_project_key(&summary.cwd); - grouped.entry(key).or_default().sessions.push(summary); + for project_path in summary.project_paths.clone() { + let key = normalize_project_key(&project_path); + let mut linked = summary.clone(); + linked.cwd = project_path; + grouped.entry(key).or_default().sessions.push(linked); + } } Err(_) => { files_skipped += 1; @@ -117,6 +172,17 @@ pub(crate) fn build_catalog(sessions_dir: &Path) -> Result { } } + let projects = finish_project_builders(grouped); + + Ok(Catalog { + sessions_dir: sessions_dir.to_path_buf(), + projects, + files_scanned, + files_skipped, + }) +} + +fn finish_project_builders(grouped: BTreeMap) -> Vec { let mut projects = Vec::with_capacity(grouped.len()); for (_, mut builder) in grouped { builder.sessions.sort_by(|left, right| { @@ -125,6 +191,10 @@ pub(crate) fn build_catalog(sessions_dir: &Path) -> Result { .cmp(&left.started_at_sort_key_ms) .then_with(|| left.file_path.cmp(&right.file_path)) }); + let mut seen = HashSet::new(); + builder + .sessions + .retain(|session| seen.insert(session.file_path.clone())); let display_path = builder .sessions .first() @@ -137,13 +207,7 @@ pub(crate) fn build_catalog(sessions_dir: &Path) -> Result { } projects.sort_by(|left, right| left.display_path.cmp(&right.display_path)); - - Ok(Catalog { - sessions_dir: sessions_dir.to_path_buf(), - projects, - files_scanned, - files_skipped, - }) + projects } pub(crate) fn load_session_detail(path: &Path) -> Result { @@ -303,8 +367,15 @@ fn scan_session_summary(path: &Path) -> Result { "turn_context" => extract_turn_context(&mut builder, &value), "response_item" => { extract_title_candidate(&mut builder, &value); - if let Some(project) = - project_identity_from_tool_call(&value, builder.cwd.as_deref()) + for project in + project_identities_from_structured_event(&value, builder.cwd.as_deref()) + { + builder.note_project_cwd(project); + } + } + "event_msg" => { + for project in + project_identities_from_structured_event(&value, builder.cwd.as_deref()) { builder.note_project_cwd(project); } @@ -439,24 +510,31 @@ impl SessionSummaryBuilder { candidate.count = candidate.count.saturating_add(1); } - fn inferred_project_cwd(&self) -> Option { - self.project_cwds - .values() - .max_by(|left, right| { - left.count.cmp(&right.count).then_with(|| { - project_path_depth(&left.display_path) - .cmp(&project_path_depth(&right.display_path)) - }) - }) - .map(|candidate| candidate.display_path.clone()) - } - fn finish(self) -> Result { - let cwd = self - .inferred_project_cwd() - .or_else(|| self.cwd.as_deref().and_then(project_identity_from_path)) + let fallback_cwd = self + .cwd + .as_deref() + .and_then(project_identity_from_path) .or_else(|| self.cwd.clone()) .ok_or_else(|| anyhow::anyhow!("Missing cwd in {}", self.file_path.display()))?; + let mut project_paths = self + .project_cwds + .values() + .map(|candidate| candidate.display_path.clone()) + .collect::>(); + if let Some(initial_project) = self.cwd.as_deref().and_then(project_identity_from_path) { + if !project_paths + .iter() + .any(|path| normalize_project_key(path) == normalize_project_key(&initial_project)) + { + project_paths.push(initial_project); + } + } + if project_paths.is_empty() { + project_paths.push(fallback_cwd.clone()); + } + project_paths.sort_by_key(|path| normalize_project_key(path)); + let cwd = project_paths.first().cloned().unwrap_or(fallback_cwd); let session_id = self .session_id .unwrap_or_else(|| self.file_path.display().to_string()); @@ -491,14 +569,11 @@ impl SessionSummaryBuilder { repo_url: self.repo_url, model_provider: self.model_provider, model: self.model, + project_paths, }) } } -fn project_path_depth(path: &str) -> usize { - Path::new(path).components().count() -} - fn extract_message_texts(payload: &serde_json::Map, part_type: &str) -> Vec { let mut texts = Vec::new(); let Some(items) = payload.get("content").and_then(Value::as_array) else { @@ -678,7 +753,7 @@ mod tests { } #[test] - fn build_catalog_prefers_structured_tool_workdir_git_root() { + fn build_catalog_links_session_to_every_structured_tool_workdir_git_root() { let root = make_temp_dir("tool-workdir"); let sessions = root.join("sessions"); let project = root.join("rustadmin-fps-diag"); @@ -722,10 +797,107 @@ mod tests { ); let catalog = build_catalog(&sessions).expect("catalog"); - assert_eq!(catalog.projects.len(), 1); + assert_eq!(catalog.projects.len(), 2); + assert!(catalog + .projects + .iter() + .any(|entry| entry.display_path == root.display().to_string())); + assert!(catalog + .projects + .iter() + .any(|entry| entry.display_path == project.display().to_string())); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn cached_project_links_add_late_projects_and_keep_many_sessions_per_project() { + let root = make_temp_dir("late-many-to-many"); + let sessions = root.join("sessions/2026/07/26"); + let project_a = root.join("project-a"); + let project_b = root.join("project-b"); + std::fs::create_dir_all(&sessions).expect("create sessions"); + std::fs::create_dir_all(project_a.join(".git")).expect("create project a"); + std::fs::create_dir_all(project_b.join(".git")).expect("create project b"); + + let first = sessions.join("first.jsonl"); + let mut body = String::from( + r#"{"type":"session_meta","payload":{"id":"first","timestamp":"2026-07-26T08:00:00Z","cwd":"/outside/launcher"}} +{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"work across projects"}]}} +"#, + ); + for _ in 0..140 { + body.push_str("{\"type\":\"event_msg\",\"payload\":{\"type\":\"noop\"}}\n"); + } + for project in [&project_a, &project_b] { + body.push_str( + &serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "arguments": serde_json::json!({"workdir": project}).to_string() + } + }) + .to_string(), + ); + body.push('\n'); + } + write_session(&first, &body); + + let second = sessions.join("second.jsonl"); + write_session( + &second, + &format!( + "{}\n{}\n", + r#"{"type":"session_meta","payload":{"id":"second","timestamp":"2026-07-26T09:00:00Z","cwd":"/outside/launcher"}}"#, + serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "arguments": serde_json::json!({"workdir": project_a}).to_string() + } + }) + ), + ); + + let mut catalog = build_catalog(&root.join("sessions")).expect("catalog"); + assert_eq!(catalog.projects.len(), 2); + catalog.reconcile_project_usage(&[ + ProjectUsageSummary { + display_path: project_a.display().to_string(), + total_tokens: 0, + cached_input_tokens: 0, + agent_time_ms: 0, + agent_runs: 0, + indexed_files: 2, + session_files: vec![first.clone(), second.clone()], + }, + ProjectUsageSummary { + display_path: project_b.display().to_string(), + total_tokens: 0, + cached_input_tokens: 0, + agent_time_ms: 0, + agent_runs: 0, + indexed_files: 1, + session_files: vec![first.clone()], + }, + ]); + assert_eq!(catalog.projects.len(), 2); + let project_a_entry = catalog + .projects + .iter() + .find(|entry| entry.display_path == project_a.display().to_string()) + .expect("project a"); + assert_eq!(project_a_entry.sessions.len(), 2); + let project_b_entry = catalog + .projects + .iter() + .find(|entry| entry.display_path == project_b.display().to_string()) + .expect("project b"); + assert_eq!(project_b_entry.sessions.len(), 1); assert_eq!( - catalog.projects[0].display_path, - project.display().to_string() + project_a_entry.sessions[1].file_path, + project_b_entry.sessions[0].file_path ); let _ = std::fs::remove_dir_all(root); diff --git a/src/read/tui.rs b/src/read/tui.rs index 73fd574..3825f93 100644 --- a/src/read/tui.rs +++ b/src/read/tui.rs @@ -3,7 +3,9 @@ use crate::read::scan::{ load_session_detail, truncate_single_line, Catalog, ProjectRecord, SessionDetail, SessionSummary, }; -use crate::usage::{format_compact_kmb, format_duration, LocalUsageSnapshot}; +use crate::usage::{ + format_compact_kmb, format_duration, normalize_project_key, LocalUsageSnapshot, +}; use anyhow::Result; use chrono::{DateTime, Local}; use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; @@ -14,7 +16,9 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; +use std::collections::hash_map::DefaultHasher; use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthStr; @@ -50,6 +54,7 @@ pub(crate) struct BrowserState { error: Option, layout: UiLayout, last_click: Option<(BrowserClickTarget, Instant)>, + usage_project_revision: Option, } impl BrowserState { @@ -84,9 +89,56 @@ impl BrowserState { error: None, layout: UiLayout::default(), last_click: None, + usage_project_revision: None, } } + fn reconcile_project_usage(&mut self, usage: &LocalUsageSnapshot) { + let mut hasher = DefaultHasher::new(); + for project in &usage.project_usage { + project.display_path.hash(&mut hasher); + project.session_files.hash(&mut hasher); + } + let revision = hasher.finish(); + if self.usage_project_revision == Some(revision) { + return; + } + + let selected_project = self + .selected_project() + .map(|project| normalize_project_key(&project.display_path)); + let selected_session = self + .selected_session() + .map(|session| session.file_path.clone()); + self.catalog.reconcile_project_usage(&usage.project_usage); + self.usage_project_revision = Some(revision); + + let project_index = selected_project + .as_deref() + .and_then(|key| { + self.catalog + .projects + .iter() + .position(|project| normalize_project_key(&project.display_path) == key) + }) + .or_else(|| (!self.catalog.projects.is_empty()).then_some(0)); + self.project_state.select(project_index); + + let session_row = project_index + .and_then(|index| self.catalog.projects.get(index)) + .and_then(|project| { + selected_session.as_ref().and_then(|path| { + project + .sessions + .iter() + .position(|session| &session.file_path == path) + .map(|index| index + 1) + }) + }) + .unwrap_or(0); + self.session_state.select(Some(session_row)); + } + fn selected_project_index(&self) -> Option { self.project_state.selected() } @@ -389,6 +441,9 @@ pub(crate) fn render( usage_error: Option<&str>, formatter: DisplayFormatter<'_>, ) { + if let Some(usage) = usage { + state.reconcile_project_usage(usage); + } let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ diff --git a/src/usage/mod.rs b/src/usage/mod.rs index a6559c5..006b6b0 100644 --- a/src/usage/mod.rs +++ b/src/usage/mod.rs @@ -18,8 +18,9 @@ const DEFAULT_MAX_SESSION_FILES_SCANNED: usize = 10_000; const DEFAULT_MAX_JSONL_LINE_BYTES: usize = 512 * 1024; const DEFAULT_SCAN_TIME_BUDGET_MS: u64 = 1500; const MAX_DISTINCT_MODELS: usize = 5_000; -const SCAN_CACHE_DB_SCHEMA_VERSION: i64 = 9; +const SCAN_CACHE_DB_SCHEMA_VERSION: i64 = 11; pub(crate) const PROJECT_IDENTITY_LINE_LIMIT: usize = 128; +const MAX_PROJECTS_PER_SESSION: usize = 256; const FORK_REPLAY_END_GAP_MS: i64 = 1_000; const FORK_REPLAY_NO_TOKEN_GRACE_MS: i64 = 2_000; pub const DEFAULT_SCAN_CACHE_MAX_ENTRIES: usize = 50_000; @@ -231,6 +232,7 @@ pub struct ProjectUsageSummary { pub agent_time_ms: i64, pub agent_runs: i64, pub indexed_files: usize, + pub session_files: Vec, } #[derive(Debug, Clone)] @@ -475,6 +477,16 @@ struct ParserState { project_cwd_counts: HashMap, #[serde(default)] project_identity_lines_seen: u32, + #[serde(default)] + active_project_cwd: Option, + #[serde(default)] + pending_project_cwd_counts: HashMap, + #[serde(default)] + project_daily: HashMap>, + #[serde(default)] + project_model_totals_by_day: HashMap>>, + #[serde(default)] + project_last_activity_ms: HashMap, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] @@ -555,6 +567,7 @@ struct ProjectUsageBuilder { agent_time_ms: i64, agent_runs: i64, indexed_files: usize, + session_files: Vec, } fn default_true() -> bool { @@ -1401,57 +1414,94 @@ fn build_project_usage_summaries( if !cache_entry_matches_candidate(entry, candidate) { continue; } - let Some(cwd) = entry - .session_cwd - .as_deref() - .filter(|cwd| !cwd.trim().is_empty()) - else { - continue; - }; - let key = normalize_project_key(cwd); - if key.is_empty() { - continue; - } - - let project = projects.entry(key).or_default(); - if project.display_path.is_empty() - || cwd.len() < project.display_path.len() - || (cwd.len() == project.display_path.len() && cwd < project.display_path.as_str()) - { - project.display_path = cwd.to_string(); + for cwd in entry_project_paths(entry) { + let key = normalize_project_key(&cwd); + let project = projects.entry(key).or_default(); + prefer_project_display_path(&mut project.display_path, &cwd); + project.indexed_files = project.indexed_files.saturating_add(1); + project.session_files.push(candidate.path.clone()); } - project.indexed_files = project.indexed_files.saturating_add(1); - for (cache_key, totals) in &entry.daily { - let Some((UsageZone::Local, _)) = split_cache_day_key(cache_key) else { + for (cwd, project_daily) in &entry.parser_state.project_daily { + let key = normalize_project_key(cwd); + if key.is_empty() { continue; - }; - project.total_tokens = project - .total_tokens - .saturating_add(totals.input.saturating_add(totals.output)); - project.cached_input_tokens = project - .cached_input_tokens - .saturating_add(totals.cached.min(totals.input)); - project.agent_time_ms = project.agent_time_ms.saturating_add(totals.agent_ms); - project.agent_runs = project.agent_runs.saturating_add(totals.agent_runs); + } + let project = projects.entry(key).or_default(); + prefer_project_display_path(&mut project.display_path, cwd); + for (cache_key, totals) in project_daily { + let Some((UsageZone::Local, _)) = split_cache_day_key(cache_key) else { + continue; + }; + project.total_tokens = project + .total_tokens + .saturating_add(totals.input.saturating_add(totals.output)); + project.cached_input_tokens = project + .cached_input_tokens + .saturating_add(totals.cached.min(totals.input)); + project.agent_time_ms = project.agent_time_ms.saturating_add(totals.agent_ms); + project.agent_runs = project.agent_runs.saturating_add(totals.agent_runs); + } } } let mut out = projects .into_values() - .map(|project| ProjectUsageSummary { - display_path: project.display_path, - total_tokens: project.total_tokens, - cached_input_tokens: project.cached_input_tokens, - agent_time_ms: project.agent_time_ms, - agent_runs: project.agent_runs, - indexed_files: project.indexed_files, + .map(|mut project| { + project.session_files.sort(); + project.session_files.dedup(); + ProjectUsageSummary { + display_path: project.display_path, + total_tokens: project.total_tokens, + cached_input_tokens: project.cached_input_tokens, + agent_time_ms: project.agent_time_ms, + agent_runs: project.agent_runs, + indexed_files: project.indexed_files, + session_files: project.session_files, + } }) .collect::>(); out.sort_by(|left, right| left.display_path.cmp(&right.display_path)); out } +fn entry_project_paths(entry: &CachedFileScanEntry) -> Vec { + let mut paths: HashMap = HashMap::new(); + for path in entry + .parser_state + .project_cwd_counts + .keys() + .chain(entry.parser_state.project_daily.keys()) + { + let key = normalize_project_key(path); + if key.is_empty() { + continue; + } + let display = paths.entry(key).or_insert_with(|| path.clone()); + prefer_project_display_path(display, path); + } + if paths.is_empty() { + if let Some(path) = fallback_project_identity(entry.session_cwd.as_deref()) { + let key = normalize_project_key(&path); + if !key.is_empty() { + paths.insert(key, path); + } + } + } + let mut out = paths.into_values().collect::>(); + out.sort_by_key(|path| normalize_project_key(path)); + out +} + +fn prefer_project_display_path(current: &mut String, candidate: &str) { + if current.is_empty() + || candidate.len() < current.len() + || (candidate.len() == current.len() && candidate < current.as_str()) + { + *current = candidate.to_string(); + } +} + fn build_project_activity( day_keys: Vec, projects: HashMap, @@ -1514,6 +1564,134 @@ fn daily_has_activity(totals: DailyTotals) -> bool { totals.input > 0 || totals.output > 0 || totals.agent_ms > 0 || totals.agent_runs > 0 } +fn fallback_project_identity(session_cwd: Option<&str>) -> Option { + let cwd = session_cwd?.trim(); + if cwd.is_empty() { + return None; + } + project_identity_from_path(cwd).or_else(|| Some(cwd.to_string())) +} + +fn ensure_project_membership(parser_state: &mut ParserState, project: &str) { + if project.trim().is_empty() { + return; + } + if parser_state.project_cwd_counts.contains_key(project) + || parser_state.project_cwd_counts.len() < MAX_PROJECTS_PER_SESSION + { + parser_state + .project_cwd_counts + .entry(project.to_string()) + .or_insert(1); + } + parser_state.project_cwd = preferred_project_identity(&parser_state.project_cwd_counts); +} + +fn note_project_context(parser_state: &mut ParserState, project: String, live: bool) { + parser_state.active_project_cwd = Some(project.clone()); + if !live { + return; + } + if parser_state.project_cwd_counts.contains_key(&project) + || parser_state.project_cwd_counts.len() < MAX_PROJECTS_PER_SESSION + { + let count = parser_state + .project_cwd_counts + .entry(project.clone()) + .or_default(); + *count = count.saturating_add(1); + } + if parser_state + .pending_project_cwd_counts + .contains_key(&project) + || parser_state.pending_project_cwd_counts.len() < MAX_PROJECTS_PER_SESSION + { + let count = parser_state + .pending_project_cwd_counts + .entry(project) + .or_default(); + *count = count.saturating_add(1); + } + parser_state.project_cwd = preferred_project_identity(&parser_state.project_cwd_counts); +} + +fn take_project_for_token( + parser_state: &mut ParserState, + session_cwd: Option<&str>, +) -> Option { + let project = preferred_project_identity(&parser_state.pending_project_cwd_counts) + .or_else(|| parser_state.active_project_cwd.clone()) + .or_else(|| fallback_project_identity(session_cwd)); + parser_state.pending_project_cwd_counts.clear(); + if let Some(project) = project.as_deref() { + ensure_project_membership(parser_state, project); + parser_state.active_project_cwd = Some(project.to_string()); + } + project +} + +fn add_project_token_delta( + parser_state: &mut ParserState, + project: &str, + timestamp_ms: i64, + delta: UsageTotals, + model: &str, +) { + for zone in [UsageZone::Local, UsageZone::Utc] { + let Some(day_key) = cache_day_key_for_timestamp_ms(timestamp_ms, zone) else { + continue; + }; + let totals = parser_state + .project_daily + .entry(project.to_string()) + .or_default() + .entry(day_key.clone()) + .or_default(); + totals.input = totals.input.saturating_add(delta.input); + totals.cached = totals.cached.saturating_add(delta.cached.min(delta.input)); + totals.output = totals.output.saturating_add(delta.output); + + let models = parser_state + .project_model_totals_by_day + .entry(project.to_string()) + .or_default() + .entry(day_key) + .or_default(); + add_model_tokens_limited(models, model.to_string(), delta.input + delta.output); + } +} + +fn add_project_agent_run(parser_state: &mut ParserState, project: &str, timestamp_ms: i64) { + ensure_project_membership(parser_state, project); + let daily = parser_state + .project_daily + .entry(project.to_string()) + .or_default(); + add_agent_run(daily, timestamp_ms); +} + +fn track_project_activity(parser_state: &mut ParserState, project: &str, timestamp_ms: i64) { + ensure_project_membership(parser_state, project); + let previous = parser_state.project_last_activity_ms.get(project).copied(); + if let Some(previous) = previous { + let delta = timestamp_ms - previous; + if delta > 0 && delta <= MAX_ACTIVITY_GAP_MS { + let daily = parser_state + .project_daily + .entry(project.to_string()) + .or_default(); + for zone in [UsageZone::Local, UsageZone::Utc] { + if let Some(day_key) = cache_day_key_for_timestamp_ms(timestamp_ms, zone) { + daily.entry(day_key).or_default().agent_ms += delta; + } + } + } + } + parser_state + .project_last_activity_ms + .insert(project.to_string(), timestamp_ms); +} + fn add_model_tokens_limited( model_totals: &mut HashMap, model: String, @@ -1544,11 +1722,9 @@ fn apply_cached_file_entry( ) { let matches_workspace = match workspace_path { None => true, - Some(filter) => entry - .session_cwd - .as_deref() - .map(|cwd| path_matches_workspace(cwd, filter)) - .unwrap_or(false), + Some(filter) => entry_project_paths(entry) + .iter() + .any(|project| path_matches_workspace(project, filter)), }; if !matches_workspace { return; @@ -1558,7 +1734,34 @@ fn apply_cached_file_entry( *matched_session_files = matched_session_files.saturating_add(1); } - for (cache_key, totals) in &entry.daily { + let mut filtered_daily: HashMap = HashMap::new(); + let mut filtered_models: HashMap> = HashMap::new(); + if let Some(filter) = workspace_path { + for (project, per_day) in &entry.parser_state.project_daily { + if !path_matches_workspace(project, filter) { + continue; + } + merge_daily_totals(&mut filtered_daily, per_day); + } + for (project, per_day) in &entry.parser_state.project_model_totals_by_day { + if !path_matches_workspace(project, filter) { + continue; + } + merge_model_totals_by_day(&mut filtered_models, per_day); + } + } + let selected_daily = if workspace_path.is_some() { + &filtered_daily + } else { + &entry.daily + }; + let selected_models = if workspace_path.is_some() { + &filtered_models + } else { + &entry.model_totals_by_day + }; + + for (cache_key, totals) in selected_daily { let Some((zone, day_key)) = split_cache_day_key(cache_key) else { continue; }; @@ -1574,7 +1777,7 @@ fn apply_cached_file_entry( dst.agent_runs += totals.agent_runs; } - for (cache_key, per_day_models) in &entry.model_totals_by_day { + for (cache_key, per_day_models) in selected_models { let Some((zone, day_key)) = split_cache_day_key(cache_key) else { continue; }; @@ -1590,8 +1793,37 @@ fn apply_cached_file_entry( } } - if let Some(cwd) = entry.session_cwd.as_deref() { - apply_project_activity(cwd, &entry.daily, daily, project_activity); + for (project, project_daily) in &entry.parser_state.project_daily { + if workspace_path.is_some_and(|filter| !path_matches_workspace(project, filter)) { + continue; + } + apply_project_activity(project, project_daily, daily, project_activity); + } +} + +fn merge_daily_totals( + target: &mut HashMap, + source: &HashMap, +) { + for (day, totals) in source { + let dst = target.entry(day.clone()).or_default(); + dst.input = dst.input.saturating_add(totals.input); + dst.cached = dst.cached.saturating_add(totals.cached); + dst.output = dst.output.saturating_add(totals.output); + dst.agent_ms = dst.agent_ms.saturating_add(totals.agent_ms); + dst.agent_runs = dst.agent_runs.saturating_add(totals.agent_runs); + } +} + +fn merge_model_totals_by_day( + target: &mut HashMap>, + source: &HashMap>, +) { + for (day, models) in source { + let dst = target.entry(day.clone()).or_default(); + for (model, tokens) in models { + add_model_tokens_limited(dst, model.clone(), *tokens); + } } } @@ -1709,6 +1941,9 @@ fn parse_file_summary( } else { ParserState::default() }; + // Project paths recur heavily in command logs. Keep filesystem probes bounded + // to one lookup per (base cwd, raw path) while this file slice is parsed. + let mut project_path_cache: HashMap> = HashMap::new(); if fork_resolution.is_fork() { parser_state.fork_parent_id = fork_resolution.parent_id.clone(); parser_state.fork_baseline = fork_resolution.baseline; @@ -1767,16 +2002,11 @@ fn parse_file_summary( if (entry_type == "session_meta" || entry_type == "turn_context") && session_cwd.is_none() { session_cwd = extract_cwd(&value); - } - if entry_type == "response_item" - && usize::try_from(parser_state.project_identity_lines_seen).unwrap_or(usize::MAX) - <= PROJECT_IDENTITY_LINE_LIMIT - { - if let Some(project) = project_identity_from_tool_call(&value, session_cwd.as_deref()) { - let count = parser_state.project_cwd_counts.entry(project).or_default(); - *count = count.saturating_add(1); - parser_state.project_cwd = - preferred_project_identity(&parser_state.project_cwd_counts); + if parser_state.active_project_cwd.is_none() { + if let Some(project) = session_cwd.as_deref().and_then(project_identity_from_path) { + ensure_project_membership(&mut parser_state, &project); + parser_state.active_project_cwd = Some(project); + } } } @@ -1796,6 +2026,16 @@ fn parse_file_summary( || fork_replay_should_skip_event(&mut fork_replay, event_timestamp_ms) }; + for project in project_identities_from_structured_event_cached( + &value, + session_cwd.as_deref(), + &mut project_path_cache, + ) { + // Replayed fork events establish the inherited working project, but + // only live events create membership and attribution evidence. + note_project_context(&mut parser_state, project, !skip_fork_replay); + } + if entry_type == "turn_context" { if uses_parent_baseline || !skip_fork_replay { if let Some(model) = extract_model_from_turn_context(&value) { @@ -1823,8 +2063,22 @@ fn parse_file_summary( if let Some(timestamp_ms) = event_timestamp_ms { if seen_runs.insert(timestamp_ms) { add_agent_run(&mut daily, timestamp_ms); + if let Some(project) = parser_state + .active_project_cwd + .clone() + .or_else(|| fallback_project_identity(session_cwd.as_deref())) + { + add_project_agent_run(&mut parser_state, &project, timestamp_ms); + } } track_activity(&mut daily, &mut last_activity_ms, timestamp_ms); + if let Some(project) = parser_state + .active_project_cwd + .clone() + .or_else(|| fallback_project_identity(session_cwd.as_deref())) + { + track_project_activity(&mut parser_state, &project, timestamp_ms); + } } continue; } @@ -1832,6 +2086,13 @@ fn parse_file_summary( if payload_type == Some("agent_reasoning") { if let Some(timestamp_ms) = event_timestamp_ms { track_activity(&mut daily, &mut last_activity_ms, timestamp_ms); + if let Some(project) = parser_state + .active_project_cwd + .clone() + .or_else(|| fallback_project_identity(session_cwd.as_deref())) + { + track_project_activity(&mut parser_state, &project, timestamp_ms); + } } continue; } @@ -1937,6 +2198,9 @@ fn parse_file_summary( continue; } + let attributed_project = + take_project_for_token(&mut parser_state, session_cwd.as_deref()); + if delta.input == 0 && delta.cached == 0 && delta.output == 0 { continue; } @@ -1947,6 +2211,15 @@ fn parse_file_summary( .clone() .or_else(|| extract_model_from_token_count(&value)) .unwrap_or_else(|| "unknown".to_string()); + if let Some(project) = attributed_project.as_deref() { + add_project_token_delta( + &mut parser_state, + project, + timestamp_ms, + delta, + &model, + ); + } for zone in [UsageZone::Local, UsageZone::Utc] { let Some(day_key) = cache_day_key_for_timestamp_ms(timestamp_ms, zone) else { continue; @@ -1968,6 +2241,9 @@ fn parse_file_summary( if let Some(timestamp_ms) = timestamp_ms { track_activity(&mut daily, &mut last_activity_ms, timestamp_ms); + if let Some(project) = attributed_project.as_deref() { + track_project_activity(&mut parser_state, project, timestamp_ms); + } } continue; } @@ -1990,12 +2266,33 @@ fn parse_file_summary( if let Some(timestamp_ms) = event_timestamp_ms { if seen_runs.insert(timestamp_ms) { add_agent_run(&mut daily, timestamp_ms); + if let Some(project) = parser_state + .active_project_cwd + .clone() + .or_else(|| fallback_project_identity(session_cwd.as_deref())) + { + add_project_agent_run(&mut parser_state, &project, timestamp_ms); + } } track_activity(&mut daily, &mut last_activity_ms, timestamp_ms); + if let Some(project) = parser_state + .active_project_cwd + .clone() + .or_else(|| fallback_project_identity(session_cwd.as_deref())) + { + track_project_activity(&mut parser_state, &project, timestamp_ms); + } } } else if payload_type != Some("message") { if let Some(timestamp_ms) = event_timestamp_ms { track_activity(&mut daily, &mut last_activity_ms, timestamp_ms); + if let Some(project) = parser_state + .active_project_cwd + .clone() + .or_else(|| fallback_project_identity(session_cwd.as_deref())) + { + track_project_activity(&mut parser_state, &project, timestamp_ms); + } } } } @@ -2008,13 +2305,13 @@ fn parse_file_summary( parser_state.fork_replay = fork_replay; parser_state.fork_live_started = fork_live_started; - let effective_cwd = parser_state - .project_cwd - .clone() - .or_else(|| session_cwd.as_deref().and_then(project_identity_from_path)) - .or(session_cwd); + if parser_state.project_cwd_counts.is_empty() { + if let Some(project) = fallback_project_identity(session_cwd.as_deref()) { + ensure_project_membership(&mut parser_state, &project); + } + } Ok(FileScanSummary { - session_cwd: effective_cwd, + session_cwd, parser_state, file_offset: file_offset.min(file_len), fully_parsed: fully_parsed && file_offset >= file_len, @@ -2135,6 +2432,29 @@ fn open_or_init_scan_cache_db(path: &Path) -> Result { ) })?; } + if schema_version < 10 { + // v10 stores many-to-many project membership and per-project token, + // activity, and model aggregates in parser_state_json. Old rows only + // contain whole-session totals and cannot be split without replay. + conn.execute("DELETE FROM file_cache;", []) + .with_context(|| { + format!( + "Unable to rebuild v10 project attribution in {}", + path.display() + ) + })?; + } + if schema_version < 11 { + // v11 also recognizes existing Git roots named explicitly in command + // arguments when the tool itself runs from a generic launcher cwd. + conn.execute("DELETE FROM file_cache;", []) + .with_context(|| { + format!( + "Unable to rebuild v11 command-path project identities in {}", + path.display() + ) + })?; + } if schema_version < SCAN_CACHE_DB_SCHEMA_VERSION { conn.execute( "UPDATE cache_meta SET value = ?1 WHERE key = 'schema_version';", @@ -2984,6 +3304,7 @@ pub(crate) fn normalize_project_key(path: &str) -> String { normalized } +#[cfg(test)] pub(crate) fn project_identity_from_tool_call( value: &Value, session_cwd: Option<&str>, @@ -3007,6 +3328,212 @@ pub(crate) fn project_identity_from_tool_call( let raw = ["workdir", "cwd", "working_directory"] .into_iter() .find_map(|key| arguments.get(key).and_then(Value::as_str))?; + project_identity_from_raw_path(raw, session_cwd) +} + +#[cfg(test)] +pub(crate) fn project_identity_from_structured_event( + value: &Value, + session_cwd: Option<&str>, +) -> Option { + project_identities_from_structured_event(value, session_cwd) + .into_iter() + .next() +} + +pub(crate) fn project_identities_from_structured_event( + value: &Value, + session_cwd: Option<&str>, +) -> Vec { + let mut cache = HashMap::new(); + project_identities_from_structured_event_cached(value, session_cwd, &mut cache) +} + +fn project_identities_from_structured_event_cached( + value: &Value, + session_cwd: Option<&str>, + cache: &mut HashMap>, +) -> Vec { + let mut projects = Vec::new(); + if value.get("type").and_then(Value::as_str) == Some("event_msg") { + let Some(payload) = value.get("payload").and_then(Value::as_object) else { + return projects; + }; + let event_type = payload.get("type").and_then(Value::as_str); + if matches!( + event_type, + Some( + "exec_command_begin" + | "exec_command_start" + | "exec_command_end" + | "thread_settings_applied" + ) + ) { + if let Some(raw) = payload.get("cwd").and_then(Value::as_str) { + if let Some(project) = resolve_project_identity_cached(raw, session_cwd, cache) { + projects.push(project); + } + } + } + return projects; + } + + if value.get("type").and_then(Value::as_str) != Some("response_item") { + return projects; + } + let Some(payload) = value.get("payload").and_then(Value::as_object) else { + return projects; + }; + if payload.get("type").and_then(Value::as_str) != Some("function_call") { + return projects; + } + + let Some(arguments) = payload.get("arguments") else { + return projects; + }; + let parsed_arguments; + let Some(arguments) = (if let Some(raw) = arguments.as_str() { + parsed_arguments = match serde_json::from_str::(raw) { + Ok(value) => value, + Err(_) => return projects, + }; + parsed_arguments.as_object() + } else { + arguments.as_object() + }) else { + return projects; + }; + + let raw_workdir = ["workdir", "cwd", "working_directory"] + .into_iter() + .find_map(|key| arguments.get(key).and_then(Value::as_str)); + if let Some(raw) = raw_workdir { + if let Some(project) = resolve_project_identity_cached(raw, session_cwd, cache) { + projects.push(project); + } + } + + let tool_name = payload.get("name").and_then(Value::as_str).unwrap_or(""); + if matches!( + tool_name, + "exec_command" | "shell_command" | "shell" | "run_command" + ) { + let command_base = raw_workdir.or(session_cwd); + for command in ["cmd", "command"] + .into_iter() + .filter_map(|key| arguments.get(key)) + .flat_map(command_strings) + { + for raw in command_path_candidates(command) { + if let Some(project) = resolve_project_identity_cached(&raw, command_base, cache) { + projects.push(project); + } + } + } + } + + projects.sort_by_key(|project| normalize_project_key(project)); + projects.dedup_by(|left, right| normalize_project_key(left) == normalize_project_key(right)); + projects +} + +fn command_strings(value: &Value) -> Vec<&str> { + match value { + Value::String(command) => vec![command.as_str()], + Value::Array(commands) => commands.iter().filter_map(Value::as_str).collect(), + _ => Vec::new(), + } +} + +fn resolve_project_identity_cached( + raw: &str, + base: Option<&str>, + cache: &mut HashMap>, +) -> Option { + let key = format!("{}\0{raw}", base.unwrap_or("")); + cache + .entry(key) + .or_insert_with(|| project_identity_from_raw_path(raw, base)) + .clone() +} + +fn command_path_candidates(command: &str) -> Vec { + const MAX_COMMAND_BYTES: usize = 64 * 1024; + const MAX_PATH_CANDIDATES: usize = 128; + if command.is_empty() || command.len() > MAX_COMMAND_BYTES { + return Vec::new(); + } + + let mut tokens = Vec::new(); + let mut token = String::new(); + let mut quote = None; + let mut escaped = false; + for ch in command.chars() { + if escaped { + token.push(ch); + escaped = false; + continue; + } + if ch == '\\' && quote != Some('\'') { + escaped = true; + continue; + } + if let Some(active_quote) = quote { + if ch == active_quote { + quote = None; + } else { + token.push(ch); + } + continue; + } + if ch == '\'' || ch == '"' { + quote = Some(ch); + } else if ch.is_whitespace() || matches!(ch, ';' | '|' | '&' | '(' | ')') { + if !token.is_empty() { + tokens.push(std::mem::take(&mut token)); + } + } else { + token.push(ch); + } + } + if !token.is_empty() { + tokens.push(token); + } + + let mut out = Vec::new(); + let mut expect_relative_path = false; + for token in tokens { + let stripped = token.trim_matches(|ch| matches!(ch, '<' | '>' | ',')); + let path = stripped + .split_once('=') + .map(|(_, value)| value) + .unwrap_or(stripped) + .trim_matches(|ch| matches!(ch, '<' | '>' | ',')); + let explicit_path = path.starts_with('/') + || path.starts_with("./") + || path.starts_with("../") + || path.starts_with("~/") + || path.starts_with("\\\\wsl") + || (path.len() >= 3 + && path.as_bytes()[1] == b':' + && matches!(path.as_bytes()[2], b'/' | b'\\')); + if !path.contains("://") + && !path.is_empty() + && path.len() <= 4096 + && (explicit_path || (expect_relative_path && !path.starts_with('-'))) + { + out.push(path.to_string()); + if out.len() == MAX_PATH_CANDIDATES { + break; + } + } + expect_relative_path = matches!(stripped, "cd" | "pushd") + || matches!(stripped, "-C" | "--directory" | "--manifest-path"); + } + out +} + +fn project_identity_from_raw_path(raw: &str, session_cwd: Option<&str>) -> Option { if let Some(identity) = project_identity_from_path(raw) { return Some(identity); } @@ -3706,6 +4233,78 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn fork_replay_inherits_project_context_without_double_counting() { + let root = make_temp_dir("fork-project-context"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + let project = root.join("project"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + std::fs::create_dir_all(project.join(".git")).expect("create project"); + + let parent_id = "33333333-3333-4333-8333-333333333333"; + let child_id = "44444444-4444-4444-8444-444444444444"; + let parent_path = sessions_root.join(format!("rollout-parent-{parent_id}.jsonl")); + let child_path = sessions_root.join(format!("rollout-child-{child_id}.jsonl")); + let fork_ms = Utc::now().timestamp_millis() - Duration::hours(1).num_milliseconds(); + + append_session_meta_line(&parent_path, fork_ms - 1_000, "/outside/launcher"); + append_json_line( + &parent_path, + serde_json::json!({ + "type": "response_item", + "timestamp": Utc.timestamp_millis_opt(fork_ms - 200).single().unwrap().to_rfc3339(), + "payload": { + "type": "function_call", + "arguments": serde_json::json!({"workdir": project}).to_string() + } + }), + ); + append_total_token_line(&parent_path, fork_ms - 100, 1_500, 1_300, 130); + + append_json_line( + &child_path, + serde_json::json!({ + "type": "session_meta", + "timestamp": Utc.timestamp_millis_opt(fork_ms).single().unwrap().to_rfc3339(), + "payload": { + "id": child_id, + "forked_from_id": parent_id, + "timestamp": Utc.timestamp_millis_opt(fork_ms).single().unwrap().to_rfc3339(), + "cwd": "/outside/launcher" + } + }), + ); + // Copied parent context establishes the inherited project but must not + // create usage or membership until the child becomes live. + append_json_line( + &child_path, + serde_json::json!({ + "type": "response_item", + "timestamp": Utc.timestamp_millis_opt(fork_ms + 20).single().unwrap().to_rfc3339(), + "payload": { + "type": "function_call", + "arguments": serde_json::json!({"workdir": project}).to_string() + } + }), + ); + append_total_token_line(&child_path, fork_ms + 100, 1_500, 1_300, 130); + append_agent_message_line(&child_path, fork_ms + 1_500); + append_total_token_line(&child_path, fork_ms + 1_600, 1_700, 1_400, 150); + + let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) + .expect("snapshot"); + let project_usage = snapshot + .project_usage_for_path(&project.display().to_string()) + .expect("project usage"); + assert_eq!(snapshot.totals.last30_days_tokens, 1_850); + assert_eq!(project_usage.total_tokens, 1_850); + assert_eq!(project_usage.indexed_files, 2); + assert_eq!(project_usage.session_files.len(), 2); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn cached_scanner_advances_through_backlog_across_refreshes() { let root = make_temp_dir("scan-backlog"); @@ -3995,6 +4594,74 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn compute_snapshot_splits_one_session_between_projects_without_double_counting() { + let root = make_temp_dir("project-many-to-many"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + let project_a = root.join("project-a"); + let project_b = root.join("project-b"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + std::fs::create_dir_all(project_a.join(".git")).expect("create project a"); + std::fs::create_dir_all(project_b.join(".git")).expect("create project b"); + + let now_ms = Utc::now().timestamp_millis(); + let session = sessions_root.join("session.jsonl"); + append_session_meta_line(&session, now_ms, "/outside/launcher"); + for (project, timestamp_ms, input, cached, output) in [ + (&project_a, now_ms + 100, 100, 25, 20), + (&project_b, now_ms + 200, 160, 35, 30), + ] { + append_json_line( + &session, + serde_json::json!({ + "type": "response_item", + "timestamp": Utc + .timestamp_millis_opt(timestamp_ms - 1) + .single() + .expect("timestamp") + .to_rfc3339(), + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({"workdir": project}).to_string() + } + }), + ); + append_total_token_line(&session, timestamp_ms, input, cached, output); + } + + let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) + .expect("snapshot"); + let a = snapshot + .project_usage_for_path(&project_a.display().to_string()) + .expect("project a usage"); + let b = snapshot + .project_usage_for_path(&project_b.display().to_string()) + .expect("project b usage"); + assert_eq!(a.total_tokens, 120); + assert_eq!(a.cached_input_tokens, 25); + assert_eq!(a.indexed_files, 1); + assert_eq!(b.total_tokens, 70); + assert_eq!(b.cached_input_tokens, 10); + assert_eq!(b.indexed_files, 1); + assert_eq!(a.total_tokens + b.total_tokens, 190); + assert_eq!(snapshot.totals.last30_days_tokens, 190); + + let filtered_a = compute_snapshot( + 30, + &codex_home, + Some(&project_a), + default_test_limits(false), + None, + ) + .expect("filtered project a snapshot"); + assert_eq!(filtered_a.totals.last30_days_tokens, 120); + assert_eq!(filtered_a.matched_session_files, 1); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn compute_snapshot_keeps_cached_totals_for_unplanned_unchanged_files() { let root = make_temp_dir("cache-unplanned"); @@ -4516,8 +5183,8 @@ mod tests { } #[test] - fn open_or_init_scan_cache_db_v9_reparses_v8_rows_for_project_identity() { - let root = make_temp_dir("cache-migrate-v9"); + fn open_or_init_scan_cache_db_v11_reparses_v10_rows_for_command_path_attribution() { + let root = make_temp_dir("cache-migrate-v11"); let sessions_root = root.join("sessions"); std::fs::create_dir_all(&sessions_root).expect("create sessions root"); let keep_path = sessions_root.join("keep.jsonl"); @@ -4527,14 +5194,14 @@ mod tests { write_forked_replay_file(&forked_path, now_ms); let db_path = root.join("comon.db"); - let conn = Connection::open(&db_path).expect("open v8 db"); + let conn = Connection::open(&db_path).expect("open v10 db"); conn.execute_batch( " CREATE TABLE cache_meta ( key TEXT PRIMARY KEY, value INTEGER NOT NULL ); - INSERT INTO cache_meta(key, value) VALUES('schema_version', 8); + INSERT INTO cache_meta(key, value) VALUES('schema_version', 10); CREATE TABLE file_cache ( file_path TEXT PRIMARY KEY, file_size INTEGER NOT NULL, @@ -4549,7 +5216,7 @@ mod tests { ); ", ) - .expect("create v8 schema"); + .expect("create v10 schema"); for path in [&keep_path, &forked_path] { conn.execute( " @@ -4612,6 +5279,101 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn thread_settings_cwd_resolves_to_project_git_root() { + let root = make_temp_dir("thread-settings-project"); + let project = root.join("project"); + let child = project.join("src"); + std::fs::create_dir_all(project.join(".git")).expect("create git marker"); + std::fs::create_dir_all(&child).expect("create child"); + let value = serde_json::json!({ + "type": "event_msg", + "payload": { + "type": "thread_settings_applied", + "cwd": child + } + }); + + assert_eq!( + project_identity_from_structured_event(&value, None), + Some( + std::fs::canonicalize(&project) + .expect("canonical project") + .display() + .to_string() + ) + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn command_paths_resolve_multiple_git_projects_from_launcher_cwd() { + let root = make_temp_dir("command-path-projects"); + let launcher = root.join("launcher"); + let project_a = root.join("project-a"); + let project_b = root.join("project-b"); + std::fs::create_dir_all(&launcher).expect("create launcher"); + std::fs::create_dir_all(project_a.join(".git")).expect("create project a"); + std::fs::create_dir_all(project_a.join("src")).expect("create project a child"); + std::fs::create_dir_all(project_b.join(".git")).expect("create project b"); + let command = format!( + "rg needle {}/src && cd ../project-b && cargo check", + project_a.display() + ); + let value = serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({ + "workdir": launcher, + "cmd": command + }).to_string() + } + }); + + let projects = + project_identities_from_structured_event(&value, Some(&root.display().to_string())); + assert_eq!(projects.len(), 2, "resolved projects: {projects:?}"); + assert!(projects.iter().any(|path| path + == &std::fs::canonicalize(&project_a) + .expect("canonical project a") + .display() + .to_string())); + assert!(projects.iter().any(|path| path + == &std::fs::canonicalize(&project_b) + .expect("canonical project b") + .display() + .to_string())); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn command_path_extraction_ignores_urls_and_plain_prose() { + let root = make_temp_dir("command-path-bounds"); + let project = root.join("project"); + std::fs::create_dir_all(project.join(".git")).expect("create project"); + let value = serde_json::json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": serde_json::json!({ + "workdir": root, + "cmd": "echo project && curl https://example.test/project" + }).to_string() + } + }); + + let projects = + project_identities_from_structured_event(&value, Some(&project.display().to_string())); + assert!(projects.is_empty(), "resolved projects: {projects:?}"); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn dominant_project_identity_wins_over_earlier_parent_workdir() { let counts = HashMap::from([