diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 028bf68e87..18a0d379e9 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -418,6 +418,7 @@ async fn run_ratatui_app( &mut tui, &config.codex_home, &config.model_provider_id, + &config.cwd, ) .await? { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 3104b32735..566be2186a 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -10,7 +10,10 @@ use codex_core::ConversationsPage; use codex_core::Cursor; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::git_info::collect_git_info; use codex_protocol::items::TurnItem; +use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::SessionMetaLine; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -69,11 +72,16 @@ pub async fn run_resume_picker( tui: &mut Tui, codex_home: &Path, default_provider: &str, + current_cwd: &Path, ) -> Result { let alt = AltScreenGuard::enter(tui); let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let default_provider = default_provider.to_string(); + let match_context = MatchContext::from_env( + current_cwd.to_path_buf(), + collect_git_info(current_cwd).await, + ); let loader_tx = bg_tx.clone(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { @@ -102,6 +110,7 @@ pub async fn run_resume_picker( alt.tui.frame_requester(), page_loader, default_provider.clone(), + match_context, ); state.load_initial_page().await?; state.request_frame(); @@ -177,6 +186,7 @@ struct PickerState { page_loader: PageLoader, view_rows: Option, default_provider: String, + match_context: MatchContext, } struct PaginationState { @@ -234,6 +244,55 @@ struct Row { preview: String, created_at: Option>, updated_at: Option>, + match_marker: String, +} + +#[derive(Clone)] +struct MatchContext { + cwd: PathBuf, + git_commit: Option, + git_branch: Option, + git_repo_url: Option, +} + +impl MatchContext { + fn from_env(cwd: PathBuf, git_info: Option) -> Self { + let (git_commit, git_branch, git_repo_url) = match git_info { + Some(git) => ( + normalize_owned(git.commit_hash), + normalize_owned(git.branch), + normalize_owned(git.repository_url), + ), + None => (None, None, None), + }; + Self { + cwd, + git_commit, + git_branch, + git_repo_url, + } + } + + #[cfg(test)] + fn empty() -> Self { + Self { + cwd: PathBuf::new(), + git_commit: None, + git_branch: None, + git_repo_url: None, + } + } +} + +fn normalize_owned(value: Option) -> Option { + value.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) } impl PickerState { @@ -242,6 +301,7 @@ impl PickerState { requester: FrameRequester, page_loader: PageLoader, default_provider: String, + match_context: MatchContext, ) -> Self { Self { codex_home, @@ -264,6 +324,7 @@ impl PickerState { page_loader, view_rows: None, default_provider, + match_context, } } @@ -407,7 +468,7 @@ impl PickerState { self.pagination.reached_scan_cap = true; } - let rows = rows_from_items(page.items); + let rows = rows_from_items(page.items, &self.match_context); for row in rows { if self.seen_paths.insert(row.path.clone()) { self.all_rows.push(row); @@ -590,11 +651,14 @@ impl PickerState { } } -fn rows_from_items(items: Vec) -> Vec { - items.into_iter().map(|item| head_to_row(&item)).collect() +fn rows_from_items(items: Vec, context: &MatchContext) -> Vec { + items + .into_iter() + .map(|item| head_to_row(&item, context)) + .collect() } -fn head_to_row(item: &ConversationItem) -> Row { +fn head_to_row(item: &ConversationItem, context: &MatchContext) -> Row { let created_at = item .created_at .as_deref() @@ -616,6 +680,7 @@ fn head_to_row(item: &ConversationItem) -> Row { preview, created_at, updated_at, + match_marker: build_match_marker(item, context), } } @@ -642,6 +707,61 @@ fn preview_from_head(head: &[serde_json::Value]) -> Option { }) } +fn build_match_marker(item: &ConversationItem, context: &MatchContext) -> String { + if context.git_commit.is_none() + && context.git_branch.is_none() + && context.git_repo_url.is_none() + && context.cwd.as_os_str().is_empty() + { + return String::new(); + } + + let Some(meta_line) = session_meta_from_head(&item.head) else { + return String::new(); + }; + + let git = meta_line.git.as_ref(); + let saved_commit = normalize_borrowed(git.and_then(|g| g.commit_hash.as_ref())); + if let (Some(current), Some(saved)) = (&context.git_commit, &saved_commit) + && current == saved { + return "***".to_string(); + } + let saved_branch = normalize_borrowed(git.and_then(|g| g.branch.as_ref())); + if let (Some(current), Some(saved)) = (&context.git_branch, &saved_branch) + && current == saved { + return "**".to_string(); + } + let saved_repo = normalize_borrowed(git.and_then(|g| g.repository_url.as_ref())); + if let (Some(current), Some(saved)) = (&context.git_repo_url, &saved_repo) + && current == saved { + return "*".to_string(); + } + if !context.cwd.as_os_str().is_empty() + && !meta_line.meta.cwd.as_os_str().is_empty() + && context.cwd == meta_line.meta.cwd + { + return "D".to_string(); + } + + String::new() +} + +fn session_meta_from_head(head: &[serde_json::Value]) -> Option { + head.iter() + .find_map(|value| serde_json::from_value::(value.clone()).ok()) +} + +fn normalize_borrowed(value: Option<&String>) -> Option { + value.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { // Render full-screen overlay let height = tui.terminal.size()?.height; @@ -720,10 +840,11 @@ fn render_list( let labels = &metrics.labels; let mut y = area.y; + let max_match_width = metrics.max_match_width; let max_created_width = metrics.max_created_width; let max_updated_width = metrics.max_updated_width; - for (idx, (row, (created_label, updated_label))) in rows[start..end] + for (idx, (row, labels)) in rows[start..end] .iter() .zip(labels[start..end].iter()) .enumerate() @@ -731,30 +852,64 @@ fn render_list( let is_sel = start + idx == state.selected; let marker = if is_sel { "> ".bold() } else { " ".into() }; let marker_width = 2usize; + let match_span = if max_match_width == 0 { + None + } else { + Some( + Span::from(format!( + "{text: 0 { + preview_width = preview_width.saturating_sub(max_match_width + 2); + } if max_created_width > 0 { preview_width = preview_width.saturating_sub(max_created_width + 2); } if max_updated_width > 0 { preview_width = preview_width.saturating_sub(max_updated_width + 2); } - let add_leading_gap = max_created_width == 0 && max_updated_width == 0; + let add_leading_gap = + max_match_width == 0 && max_created_width == 0 && max_updated_width == 0; if add_leading_gap { preview_width = preview_width.saturating_sub(2); } let preview = truncate_text(&row.preview, preview_width); let mut spans: Vec = vec![marker]; + if let Some(match_column) = match_span { + spans.push(match_column); + spans.push(" ".into()); + } if let Some(created) = created_span { spans.push(created); spans.push(" ".into()); @@ -868,6 +1023,15 @@ fn render_column_headers( } let mut spans: Vec = vec![" ".into()]; + if metrics.max_match_width > 0 { + let label = format!( + "{text: 0 { let label = format!( "{text:, + labels: Vec, +} + +struct RowLabels { + match_label: String, + created_label: String, + updated_label: String, } fn calculate_column_metrics(rows: &[Row]) -> ColumnMetrics { - let mut labels: Vec<(String, String)> = Vec::with_capacity(rows.len()); + let mut labels: Vec = Vec::with_capacity(rows.len()); + let mut max_match_width = UnicodeWidthStr::width("Sync"); let mut max_created_width = UnicodeWidthStr::width("Created"); let mut max_updated_width = UnicodeWidthStr::width("Updated"); for row in rows { + let match_label = row.match_marker.clone(); let created = format_created_label(row); let updated = format_updated_label(row); + max_match_width = max_match_width.max(UnicodeWidthStr::width(match_label.as_str())); max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str())); max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str())); - labels.push((created, updated)); + labels.push(RowLabels { + match_label, + created_label: created, + updated_label: updated, + }); } ColumnMetrics { + max_match_width, max_created_width, max_updated_width, labels, @@ -920,10 +1099,16 @@ fn calculate_column_metrics(rows: &[Row]) -> ColumnMetrics { mod tests { use super::*; use chrono::Duration; + use codex_protocol::ConversationId; + use codex_protocol::protocol::GitInfo; + use codex_protocol::protocol::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::SessionSource; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use serde_json::json; use std::future::Future; use std::path::PathBuf; @@ -954,6 +1139,47 @@ mod tests { } } + fn conversation_with_meta( + cwd: &str, + commit: Option<&str>, + branch: Option<&str>, + repo: Option<&str>, + ) -> ConversationItem { + let git = if commit.is_some() || branch.is_some() || repo.is_some() { + Some(GitInfo { + commit_hash: commit.map(std::string::ToString::to_string), + branch: branch.map(std::string::ToString::to_string), + repository_url: repo.map(std::string::ToString::to_string), + }) + } else { + None + }; + + let session_meta = SessionMetaLine { + meta: SessionMeta { + id: ConversationId::default(), + timestamp: "2025-01-01T00:00:00Z".to_string(), + cwd: PathBuf::from(cwd), + originator: String::new(), + cli_version: String::new(), + instructions: None, + source: SessionSource::Cli, + model_provider: None, + }, + git, + }; + + let meta_value = serde_json::to_value(session_meta).expect("serialize meta"); + + ConversationItem { + path: PathBuf::from("/tmp/session.jsonl"), + head: vec![meta_value], + tail: Vec::new(), + created_at: Some("2025-01-01T00:00:00Z".into()), + updated_at: Some("2025-01-01T00:00:00Z".into()), + } + } + fn cursor_from_str(repr: &str) -> Cursor { serde_json::from_str::(&format!("\"{repr}\"")) .expect("cursor format should deserialize") @@ -1034,7 +1260,7 @@ mod tests { created_at: Some("2025-01-02T00:00:00Z".into()), updated_at: Some("2025-01-02T00:00:00Z".into()), }; - let rows = rows_from_items(vec![a, b]); + let rows = rows_from_items(vec![a, b], &MatchContext::empty()); assert_eq!(rows.len(), 2); // Preserve the given order even if timestamps differ; backend already provides newest-first. assert!(rows[0].preview.contains('A')); @@ -1063,7 +1289,7 @@ mod tests { updated_at: Some("2025-01-01T01:00:00Z".into()), }; - let row = head_to_row(&item); + let row = head_to_row(&item, &MatchContext::empty()); let expected_created = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z") .unwrap() .with_timezone(&Utc); @@ -1075,6 +1301,60 @@ mod tests { assert_eq!(row.updated_at, Some(expected_updated)); } + #[test] + fn match_label_prefers_hash_then_branch_then_repo_then_dir() { + let context = MatchContext { + cwd: PathBuf::from("/work/project"), + git_commit: Some("abc123".to_string()), + git_branch: Some("main".to_string()), + git_repo_url: Some("git@example.com:repo.git".to_string()), + }; + + let hash_item = conversation_with_meta( + "/different", + Some("abc123"), + Some("feature"), + Some("git@example.com:repo.git"), + ); + assert_eq!(build_match_marker(&hash_item, &context), "***"); + + let branch_item = conversation_with_meta( + "/work/project", + Some("zzz999"), + Some("main"), + Some("git@example.com:repo.git"), + ); + assert_eq!(build_match_marker(&branch_item, &context), "**"); + + let repo_item = conversation_with_meta( + "/work/project", + Some("zzz999"), + Some("feature"), + Some("git@example.com:repo.git"), + ); + assert_eq!(build_match_marker(&repo_item, &context), "*"); + + let dir_item = conversation_with_meta("/work/project", None, None, None); + assert_eq!(build_match_marker(&dir_item, &context), "D"); + + let miss_item = conversation_with_meta( + "/elsewhere", + Some("zzz999"), + Some("feature"), + Some("git@example.com:other.git"), + ); + assert_eq!(build_match_marker(&miss_item, &context), ""); + + let no_meta_item = ConversationItem { + path: PathBuf::from("/tmp/none.jsonl"), + head: Vec::new(), + tail: Vec::new(), + created_at: None, + updated_at: None, + }; + assert_eq!(build_match_marker(&no_meta_item, &context), ""); + } + #[test] fn resume_table_snapshot() { use crate::custom_terminal::Terminal; @@ -1088,6 +1368,7 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + MatchContext::empty(), ); let now = Utc::now(); @@ -1097,18 +1378,21 @@ mod tests { preview: String::from("Fix resume picker timestamps"), created_at: Some(now - Duration::minutes(16)), updated_at: Some(now - Duration::seconds(42)), + match_marker: String::new(), }, Row { path: PathBuf::from("/tmp/b.jsonl"), preview: String::from("Investigate lazy pagination cap"), created_at: Some(now - Duration::hours(1)), updated_at: Some(now - Duration::minutes(35)), + match_marker: String::new(), }, Row { path: PathBuf::from("/tmp/c.jsonl"), preview: String::from("Explain the codebase"), created_at: Some(now - Duration::hours(2)), updated_at: Some(now - Duration::hours(2)), + match_marker: String::new(), }, ]; state.all_rows = rows.clone(); @@ -1148,6 +1432,7 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + MatchContext::empty(), ); state.reset_pagination(); @@ -1214,6 +1499,7 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + MatchContext::empty(), ); state.reset_pagination(); state.ingest_page(page( @@ -1243,6 +1529,7 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + MatchContext::empty(), ); let mut items = Vec::new(); @@ -1291,6 +1578,7 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + MatchContext::empty(), ); let mut items = Vec::new(); @@ -1335,6 +1623,7 @@ mod tests { FrameRequester::test_dummy(), loader, String::from("openai"), + MatchContext::empty(), ); state.reset_pagination(); state.ingest_page(page( diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap index 0883651432..649bca4d2e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap @@ -2,7 +2,7 @@ source: tui/src/resume_picker.rs expression: snapshot --- - Created Updated Conversation - 16 minutes ago 42 seconds ago Fix resume picker timestamps -> 1 hour ago 35 minutes ago Investigate lazy pagination cap - 2 hours ago 2 hours ago Explain the codebase + Sync Created Updated Conversation + 16 minutes ago 42 seconds ago Fix resume picker timestamps +> 1 hour ago 35 minutes ago Investigate lazy pagination cap + 2 hours ago 2 hours ago Explain the codebase