diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 34ea8c8773..a99f380573 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1620,6 +1620,7 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.2.1", "url", + "uuid", "vt100", ] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index d9906b2f01..a084652fa1 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -109,3 +109,4 @@ pretty_assertions = { workspace = true } rand = { workspace = true } serial_test = { workspace = true } vt100 = { workspace = true } +uuid = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d0e057102c..4ff5cd790d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -468,6 +468,73 @@ impl App { } tui.frame_requester().schedule_frame(); } + AppEvent::OpenResumePicker => { + match crate::resume_picker::run_resume_picker( + tui, + &self.config.codex_home, + &self.config.model_provider_id, + false, + ) + .await? + { + ResumeSelection::Resume(path) => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + match self + .server + .resume_conversation_from_rollout( + self.config.clone(), + path.clone(), + self.auth_manager.clone(), + ) + .await + { + Ok(resumed) => { + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to resume session from {}: {err}", + path.display() + )); + } + } + } + ResumeSelection::Exit | ResumeSelection::StartFresh => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index cf494f57d6..944eeda810 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -22,6 +22,9 @@ pub(crate) enum AppEvent { /// Start a new session. NewSession, + /// Open the resume picker inside the running TUI session. + OpenResumePicker, + /// Request to exit the application gracefully. ExitRequest, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6b42e5134c..a000e9c613 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2357,6 +2357,62 @@ mod tests { } } + #[test] + fn slash_popup_resume_for_res_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type "/res" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + // Snapshot should show /resume as the first entry for /res. + insta::assert_snapshot!("slash_popup_res", terminal.backend()); + } + + #[test] + fn slash_popup_resume_for_res_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "resume") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/res'") + } + None => panic!("no selected command for '/res'"), + }, + _ => panic!("slash popup not active after typing '/res'"), + } + } + // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap new file mode 100644 index 0000000000..df8ea36e63 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2385 +expression: terminal.backend() +--- +" " +"› /res " +" " +" " +" " +" /resume resume a saved chat " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 53c77f82be..b9d990a42c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1454,6 +1454,9 @@ impl ChatWidget { SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } + SlashCommand::Resume => { + self.app_event_tx.send(AppEvent::OpenResumePicker); + } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); if init_target.exists() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 7b044e8dfb..050bf913e8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1096,6 +1096,15 @@ fn slash_exit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); } +#[test] +fn slash_resume_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.dispatch_command(SlashCommand::Resume); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); +} + #[test] fn slash_undo_sends_op() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 6a21496d34..8bc1ec851d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -113,7 +113,7 @@ pub async fn run_resume_picker( show_all, filter_cwd, ); - state.load_initial_page().await?; + state.start_initial_load(); state.request_frame(); let mut tui_events = alt.tui.event_stream().fuse(); @@ -359,25 +359,28 @@ impl PickerState { Ok(None) } - async fn load_initial_page(&mut self) -> Result<()> { - let provider_filter = vec![self.default_provider.clone()]; - let page = RolloutRecorder::list_conversations( - &self.codex_home, - PAGE_SIZE, - None, - INTERACTIVE_SESSION_SOURCES, - Some(provider_filter.as_slice()), - self.default_provider.as_str(), - ) - .await?; + fn start_initial_load(&mut self) { self.reset_pagination(); self.all_rows.clear(); self.filtered_rows.clear(); self.seen_paths.clear(); self.search_state = SearchState::Idle; self.selected = 0; - self.ingest_page(page); - Ok(()) + + let request_token = self.allocate_request_token(); + self.pagination.loading = LoadingState::Pending(PendingLoad { + request_token, + search_token: None, + }); + self.request_frame(); + + (self.page_loader)(PageLoadRequest { + codex_home: self.codex_home.clone(), + cursor: None, + request_token, + search_token: None, + default_provider: self.default_provider.clone(), + }); } fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> { @@ -1275,6 +1278,166 @@ mod tests { assert_snapshot!("resume_picker_table", snapshot); } + #[test] + fn resume_picker_screen_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + use uuid::Uuid; + + // Create real rollout files so the snapshot uses the actual listing pipeline. + let tempdir = tempfile::tempdir().expect("tempdir"); + let sessions_root = tempdir.path().join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("mkdir sessions root"); + + let now = Utc::now(); + + // Helper to write a rollout file with minimal meta + one user message. + let write_rollout = |ts: DateTime, cwd: &str, branch: &str, preview: &str| { + let dir = sessions_root + .join(ts.format("%Y").to_string()) + .join(ts.format("%m").to_string()) + .join(ts.format("%d").to_string()); + std::fs::create_dir_all(&dir).expect("mkdir date dirs"); + let filename = format!( + "rollout-{}-{}.jsonl", + ts.format("%Y-%m-%dT%H-%M-%S"), + Uuid::new_v4() + ); + let path = dir.join(filename); + let meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "SessionMeta": { + "meta": { + "id": Uuid::new_v4(), + "timestamp": ts.to_rfc3339(), + "cwd": cwd, + "originator": "user", + "cli_version": "0.0.0", + "instructions": null, + "source": "Cli", + "model_provider": "openai", + } + } + } + }); + let user = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "UserMessage": { + "message": preview, + "images": null + } + } + } + }); + let branch_meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "SessionMeta": { + "meta": { + "git_branch": branch + } + } + } + } + }); + std::fs::write(&path, format!("{meta}\n{user}\n{branch_meta}\n")) + .expect("write rollout"); + }; + + write_rollout( + now - Duration::seconds(42), + "/tmp/project", + "feature/resume", + "Fix resume picker timestamps", + ); + write_rollout( + now - Duration::minutes(35), + "/tmp/other", + "main", + "Investigate lazy pagination cap", + ); + + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + true, + None, + ); + + let page = block_on_future(RolloutRecorder::list_conversations( + &state.codex_home, + PAGE_SIZE, + None, + INTERACTIVE_SESSION_SOURCES, + Some(&[String::from("openai")]), + "openai", + )) + .expect("list conversations"); + + let rows = rows_from_items(page.items); + state.all_rows = rows.clone(); + state.filtered_rows = rows; + state.view_rows = Some(4); + state.selected = 0; + state.scroll_top = 0; + state.update_view_rows(4); + + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + + let width: u16 = 80; + let height: u16 = 9; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + let [header, search, columns, list, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + ]) + .areas(area); + + frame.render_widget_ref( + Line::from(vec!["Resume a previous session".bold().cyan()]), + header, + ); + + frame.render_widget_ref(Line::from("Type to search".dim()), search); + + render_column_headers(&mut frame, columns, &metrics); + render_list(&mut frame, list, &state, &metrics); + + let hint_line: Line = vec![ + key_hint::plain(KeyCode::Enter).into(), + " to resume ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " to start new ".dim(), + " ".dim(), + key_hint::ctrl(KeyCode::Char('c')).into(), + " to quit ".dim(), + ] + .into(); + frame.render_widget_ref(hint_line, hint); + } + terminal.flush().expect("flush"); + + let snapshot = terminal.backend().to_string(); + assert_snapshot!("resume_picker_screen", snapshot); + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 969d279b07..47b330cba6 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -16,6 +16,7 @@ pub enum SlashCommand { Approvals, Review, New, + Resume, Init, Compact, Undo, @@ -40,6 +41,7 @@ impl SlashCommand { SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", + SlashCommand::Resume => "resume a saved chat", SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -64,6 +66,7 @@ impl SlashCommand { pub fn available_during_task(self) -> bool { match self { SlashCommand::New + | SlashCommand::Resume | SlashCommand::Init | SlashCommand::Compact | SlashCommand::Undo diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap new file mode 100644 index 0000000000..79a169a06d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/resume_picker.rs +assertion_line: 1438 +expression: snapshot +--- +Resume a previous session +Type to search + Updated Branch CWD Conversation +No sessions yet + + + + +enter to resume esc to start new ctrl + c to quit diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4c1a244764..6961461d42 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -16,6 +16,7 @@ Control Codex’s behavior during an interactive session with slash commands. | `/approvals` | choose what Codex can do without approval | | `/review` | review my current changes and find issues | | `/new` | start a new chat during a conversation | +| `/resume` | resume an old chat | | `/init` | create an AGENTS.md file with instructions for Codex | | `/compact` | summarize conversation to prevent hitting the context limit | | `/undo` | ask Codex to undo a turn |