mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
2 Commits
main
...
pap/resume
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72004a78a3 | ||
|
|
d34a62020d |
@@ -120,6 +120,7 @@ pub use rollout::list::parse_cursor;
|
||||
pub use rollout::list::read_head_for_summary;
|
||||
pub use rollout::list::read_session_meta_line;
|
||||
pub use rollout::rollout_date_parts;
|
||||
pub use rollout::session_index::find_thread_names_by_ids;
|
||||
pub use transport_manager::TransportManager;
|
||||
mod function_tool;
|
||||
mod state;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
@@ -8,6 +10,7 @@ use std::path::PathBuf;
|
||||
use codex_protocol::ThreadId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const SESSION_INDEX_FILE: &str = "session_index.jsonl";
|
||||
@@ -76,6 +79,38 @@ pub async fn find_thread_name_by_id(
|
||||
Ok(entry.map(|entry| entry.thread_name))
|
||||
}
|
||||
|
||||
/// Find the latest thread names for a batch of thread ids.
|
||||
pub async fn find_thread_names_by_ids(
|
||||
codex_home: &Path,
|
||||
thread_ids: &HashSet<ThreadId>,
|
||||
) -> std::io::Result<HashMap<ThreadId, String>> {
|
||||
let path = session_index_path(codex_home);
|
||||
if thread_ids.is_empty() || !path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let file = tokio::fs::File::open(&path).await?;
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut names = HashMap::with_capacity(thread_ids.len());
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
let name = entry.thread_name.trim();
|
||||
if !name.is_empty() && thread_ids.contains(&entry.id) {
|
||||
names.insert(entry.id, name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Find the most recently updated thread id for a thread name, if any.
|
||||
pub async fn find_thread_id_by_name(
|
||||
codex_home: &Path,
|
||||
@@ -197,6 +232,8 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::TempDir;
|
||||
fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> {
|
||||
let mut out = String::new();
|
||||
@@ -279,6 +316,44 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_thread_names_by_ids_prefers_latest_entry() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id1 = ThreadId::new();
|
||||
let id2 = ThreadId::new();
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id: id1,
|
||||
thread_name: "first".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id: id2,
|
||||
thread_name: "other".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id: id1,
|
||||
thread_name: "latest".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
ids.insert(id1);
|
||||
ids.insert(id2);
|
||||
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert(id1, "latest".to_string());
|
||||
expected.insert(id2, "other".to_string());
|
||||
|
||||
let found = find_thread_names_by_ids(temp.path(), &ids).await?;
|
||||
assert_eq!(found, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -11,6 +12,7 @@ use codex_core::RolloutRecorder;
|
||||
use codex_core::ThreadItem;
|
||||
use codex_core::ThreadSortKey;
|
||||
use codex_core::ThreadsPage;
|
||||
use codex_core::find_thread_names_by_ids;
|
||||
use codex_core::path_utils;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use color_eyre::eyre::Result;
|
||||
@@ -34,12 +36,12 @@ use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
|
||||
const PAGE_SIZE: usize = 25;
|
||||
const LOAD_NEAR_THRESHOLD: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionSelection {
|
||||
StartFresh,
|
||||
@@ -97,8 +99,9 @@ enum BackgroundEvent {
|
||||
}
|
||||
|
||||
/// Interactive session picker that lists recorded rollout files with simple
|
||||
/// search and pagination. Shows the first user input as the preview, relative
|
||||
/// time (e.g., "5 seconds ago"), and the absolute path.
|
||||
/// search and pagination. Shows the session name when available, otherwise the
|
||||
/// first user input as the preview, relative time (e.g., "5 seconds ago"), and
|
||||
/// the absolute path.
|
||||
pub async fn run_resume_picker(
|
||||
tui: &mut Tui,
|
||||
codex_home: &Path,
|
||||
@@ -210,7 +213,7 @@ async fn run_session_picker(
|
||||
}
|
||||
}
|
||||
Some(event) = background_events.next() => {
|
||||
state.handle_background_event(event)?;
|
||||
state.handle_background_event(event).await?;
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
@@ -257,6 +260,7 @@ struct PickerState {
|
||||
show_all: bool,
|
||||
filter_cwd: Option<PathBuf>,
|
||||
action: SessionPickerAction,
|
||||
thread_name_cache: HashMap<ThreadId, Option<String>>,
|
||||
}
|
||||
|
||||
struct PaginationState {
|
||||
@@ -312,12 +316,32 @@ impl SearchState {
|
||||
struct Row {
|
||||
path: PathBuf,
|
||||
preview: String,
|
||||
thread_id: Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
cwd: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn display_preview(&self) -> &str {
|
||||
self.thread_name.as_deref().unwrap_or(&self.preview)
|
||||
}
|
||||
|
||||
fn matches_query(&self, query: &str) -> bool {
|
||||
if self.preview.to_lowercase().contains(query) {
|
||||
return true;
|
||||
}
|
||||
if let Some(thread_name) = self.thread_name.as_ref()
|
||||
&& thread_name.to_lowercase().contains(query)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerState {
|
||||
fn new(
|
||||
codex_home: PathBuf,
|
||||
@@ -352,6 +376,7 @@ impl PickerState {
|
||||
show_all,
|
||||
filter_cwd,
|
||||
action,
|
||||
thread_name_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +478,7 @@ impl PickerState {
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> {
|
||||
async fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> {
|
||||
match event {
|
||||
BackgroundEvent::PageLoaded {
|
||||
request_token,
|
||||
@@ -470,6 +495,7 @@ impl PickerState {
|
||||
self.pagination.loading = LoadingState::Idle;
|
||||
let page = page.map_err(color_eyre::Report::from)?;
|
||||
self.ingest_page(page);
|
||||
self.update_thread_names().await;
|
||||
let completed_token = pending.search_token.or(search_token);
|
||||
self.continue_search_if_token_matches(completed_token);
|
||||
}
|
||||
@@ -508,6 +534,48 @@ impl PickerState {
|
||||
self.apply_filter();
|
||||
}
|
||||
|
||||
async fn update_thread_names(&mut self) {
|
||||
let mut missing_ids = HashSet::new();
|
||||
for row in &self.all_rows {
|
||||
let Some(thread_id) = row.thread_id else {
|
||||
continue;
|
||||
};
|
||||
if self.thread_name_cache.contains_key(&thread_id) {
|
||||
continue;
|
||||
}
|
||||
missing_ids.insert(thread_id);
|
||||
}
|
||||
|
||||
if missing_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let names = find_thread_names_by_ids(&self.codex_home, &missing_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for thread_id in missing_ids {
|
||||
let thread_name = names.get(&thread_id).cloned();
|
||||
self.thread_name_cache.insert(thread_id, thread_name);
|
||||
}
|
||||
|
||||
let mut updated = false;
|
||||
for row in self.all_rows.iter_mut() {
|
||||
let Some(thread_id) = row.thread_id else {
|
||||
continue;
|
||||
};
|
||||
let thread_name = self.thread_name_cache.get(&thread_id).cloned().flatten();
|
||||
if row.thread_name == thread_name {
|
||||
continue;
|
||||
}
|
||||
row.thread_name = thread_name;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
self.apply_filter();
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let base_iter = self
|
||||
.all_rows
|
||||
@@ -517,10 +585,7 @@ impl PickerState {
|
||||
self.filtered_rows = base_iter.cloned().collect();
|
||||
} else {
|
||||
let q = self.query.to_lowercase();
|
||||
self.filtered_rows = base_iter
|
||||
.filter(|r| r.preview.to_lowercase().contains(&q))
|
||||
.cloned()
|
||||
.collect();
|
||||
self.filtered_rows = base_iter.filter(|r| r.matches_query(&q)).cloned().collect();
|
||||
}
|
||||
if self.selected >= self.filtered_rows.len() {
|
||||
self.selected = self.filtered_rows.len().saturating_sub(1);
|
||||
@@ -712,7 +777,7 @@ fn head_to_row(item: &ThreadItem) -> Row {
|
||||
.and_then(parse_timestamp_str)
|
||||
.or(created_at);
|
||||
|
||||
let (cwd, git_branch) = extract_session_meta_from_head(&item.head);
|
||||
let (cwd, git_branch, thread_id) = extract_session_meta_from_head(&item.head);
|
||||
let preview = preview_from_head(&item.head)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -721,6 +786,8 @@ fn head_to_row(item: &ThreadItem) -> Row {
|
||||
Row {
|
||||
path: item.path.clone(),
|
||||
preview,
|
||||
thread_id,
|
||||
thread_name: None,
|
||||
created_at,
|
||||
updated_at,
|
||||
cwd,
|
||||
@@ -728,15 +795,18 @@ fn head_to_row(item: &ThreadItem) -> Row {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option<PathBuf>, Option<String>) {
|
||||
fn extract_session_meta_from_head(
|
||||
head: &[serde_json::Value],
|
||||
) -> (Option<PathBuf>, Option<String>, Option<ThreadId>) {
|
||||
for value in head {
|
||||
if let Ok(meta_line) = serde_json::from_value::<SessionMetaLine>(value.clone()) {
|
||||
let cwd = Some(meta_line.meta.cwd);
|
||||
let git_branch = meta_line.git.and_then(|git| git.branch);
|
||||
return (cwd, git_branch);
|
||||
let thread_id = Some(meta_line.meta.id);
|
||||
return (cwd, git_branch, thread_id);
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
(None, None, None)
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
@@ -909,7 +979,7 @@ fn render_list(
|
||||
if add_leading_gap {
|
||||
preview_width = preview_width.saturating_sub(2);
|
||||
}
|
||||
let preview = truncate_text(&row.preview, preview_width);
|
||||
let preview = truncate_text(row.display_preview(), preview_width);
|
||||
let mut spans: Vec<Span> = vec![marker];
|
||||
if let Some(updated) = updated_span {
|
||||
spans.push(updated);
|
||||
@@ -1252,6 +1322,22 @@ mod tests {
|
||||
assert_eq!(row.updated_at, Some(expected_updated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_display_preview_prefers_thread_name() {
|
||||
let row = Row {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
preview: String::from("first message"),
|
||||
thread_id: None,
|
||||
thread_name: Some(String::from("My session")),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
};
|
||||
|
||||
assert_eq!(row.display_preview(), "My session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_table_snapshot() {
|
||||
use crate::custom_terminal::Terminal;
|
||||
@@ -1275,6 +1361,8 @@ mod tests {
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
preview: String::from("Fix resume picker timestamps"),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
created_at: Some(now - Duration::minutes(16)),
|
||||
updated_at: Some(now - Duration::seconds(42)),
|
||||
cwd: None,
|
||||
@@ -1283,6 +1371,8 @@ mod tests {
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/b.jsonl"),
|
||||
preview: String::from("Investigate lazy pagination cap"),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
created_at: Some(now - Duration::hours(1)),
|
||||
updated_at: Some(now - Duration::minutes(35)),
|
||||
cwd: None,
|
||||
@@ -1291,6 +1381,8 @@ mod tests {
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/c.jsonl"),
|
||||
preview: String::from("Explain the codebase"),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
created_at: Some(now - Duration::hours(2)),
|
||||
updated_at: Some(now - Duration::hours(2)),
|
||||
cwd: None,
|
||||
@@ -1674,8 +1766,8 @@ mod tests {
|
||||
assert_eq!(state.selected, state.filtered_rows.len().saturating_sub(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_query_loads_until_match_and_respects_scan_cap() {
|
||||
#[tokio::test]
|
||||
async fn set_query_loads_until_match_and_respects_scan_cap() {
|
||||
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let request_sink = recorded_requests.clone();
|
||||
let loader: PageLoader = Arc::new(move |req: PageLoadRequest| {
|
||||
@@ -1726,6 +1818,7 @@ mod tests {
|
||||
false,
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second_request = {
|
||||
@@ -1753,6 +1846,7 @@ mod tests {
|
||||
false,
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!state.filtered_rows.is_empty());
|
||||
@@ -1772,6 +1866,7 @@ mod tests {
|
||||
search_token: second_request.search_token,
|
||||
page: Ok(page(Vec::new(), None, 0, false)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recorded_requests.lock().unwrap().len(), 1);
|
||||
|
||||
@@ -1781,6 +1876,7 @@ mod tests {
|
||||
search_token: active_request.search_token,
|
||||
page: Ok(page(Vec::new(), None, 3, true)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(state.filtered_rows.is_empty());
|
||||
|
||||
Reference in New Issue
Block a user