Compare commits

...

1 Commits

Author SHA1 Message Date
Joey Yu
23b53ebe6c tui: show sync markers in resume picker 2025-10-28 17:03:57 -04:00
3 changed files with 307 additions and 17 deletions

View File

@@ -418,6 +418,7 @@ async fn run_ratatui_app(
&mut tui,
&config.codex_home,
&config.model_provider_id,
&config.cwd,
)
.await?
{

View File

@@ -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<ResumeSelection> {
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<usize>,
default_provider: String,
match_context: MatchContext,
}
struct PaginationState {
@@ -234,6 +244,55 @@ struct Row {
preview: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
match_marker: String,
}
#[derive(Clone)]
struct MatchContext {
cwd: PathBuf,
git_commit: Option<String>,
git_branch: Option<String>,
git_repo_url: Option<String>,
}
impl MatchContext {
fn from_env(cwd: PathBuf, git_info: Option<GitInfo>) -> 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<String>) -> Option<String> {
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<ConversationItem>) -> Vec<Row> {
items.into_iter().map(|item| head_to_row(&item)).collect()
fn rows_from_items(items: Vec<ConversationItem>, context: &MatchContext) -> Vec<Row> {
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<String> {
})
}
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<SessionMetaLine> {
head.iter()
.find_map(|value| serde_json::from_value::<SessionMetaLine>(value.clone()).ok())
}
fn normalize_borrowed(value: Option<&String>) -> Option<String> {
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:<width$}",
text = &labels.match_label,
width = max_match_width
))
.dim(),
)
};
let created_span = if max_created_width == 0 {
None
} else {
Some(Span::from(format!("{created_label:<max_created_width$}")).dim())
Some(
Span::from(format!(
"{text:<width$}",
text = &labels.created_label,
width = max_created_width
))
.dim(),
)
};
let updated_span = if max_updated_width == 0 {
None
} else {
Some(Span::from(format!("{updated_label:<max_updated_width$}")).dim())
Some(
Span::from(format!(
"{text:<width$}",
text = &labels.updated_label,
width = max_updated_width
))
.dim(),
)
};
let mut preview_width = area.width as usize;
preview_width = preview_width.saturating_sub(marker_width);
if max_match_width > 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<Span> = 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<Span> = vec![" ".into()];
if metrics.max_match_width > 0 {
let label = format!(
"{text:<width$}",
text = "Sync",
width = metrics.max_match_width
);
spans.push(Span::from(label).bold());
spans.push(" ".into());
}
if metrics.max_created_width > 0 {
let label = format!(
"{text:<width$}",
@@ -891,25 +1055,40 @@ fn render_column_headers(
}
struct ColumnMetrics {
max_match_width: usize,
max_created_width: usize,
max_updated_width: usize,
labels: Vec<(String, String)>,
labels: Vec<RowLabels>,
}
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<RowLabels> = 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::<Cursor>(&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(

View File

@@ -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