TUI: Add session resume picker (--resume) and quick resume (--continue) (#3135)

Adds a TUI resume flow with an interactive picker and quick resume.

- CLI: 
  - --resume / -r: open picker to resume a prior session
  - --continue   / -l: resume the most recent session (no picker)
- Behavior on resume: initial history is replayed, welcome banner
hidden, and the first redraw is suppressed to avoid flicker.
- Implementation:
- New tui/src/resume_picker.rs (paginated listing via
RolloutRecorder::list_conversations)
  - App::run accepts ResumeSelection; resumes from disk when requested
- ChatWidget refactor with ChatWidgetInit and new_from_existing; replays
initial messages
- Tests: cover picker sorting/preview extraction and resumed-history
rendering.
- Docs: getting-started updated with flags and picker usage.



https://github.com/user-attachments/assets/1bb6469b-e5d1-42f6-bec6-b1ae6debda3b
This commit is contained in:
Ahmed Ibrahim
2025-09-03 23:20:40 -07:00
committed by GitHub
parent 0f4ae1b5b0
commit 234c0a0469
11 changed files with 804 additions and 55 deletions

View File

@@ -61,7 +61,10 @@ pub mod spawn;
pub mod terminal; pub mod terminal;
mod tool_apply_patch; mod tool_apply_patch;
pub mod turn_diff_tracker; pub mod turn_diff_tracker;
pub use rollout::RolloutRecorder;
pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage; pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
mod user_notification; mod user_notification;
pub mod util; pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1; pub use apply_patch::CODEX_APPLY_PATCH_ARG1;

View File

@@ -34,7 +34,8 @@ pub struct ConversationItem {
} }
/// Hard cap to bound worstcase work per request. /// Hard cap to bound worstcase work per request.
const MAX_SCAN_FILES: usize = 50_000; const MAX_SCAN_FILES: usize = 10_000;
const HEAD_RECORD_LIMIT: usize = 10;
/// Pagination cursor identifying a file by timestamp and UUID. /// Pagination cursor identifying a file by timestamp and UUID.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -166,7 +167,9 @@ async fn traverse_directories_for_paths(
if items.len() == page_size { if items.len() == page_size {
break 'outer; break 'outer;
} }
let head = read_first_jsonl_records(&path, 5).await.unwrap_or_default(); let head = read_first_jsonl_records(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or_default();
items.push(ConversationItem { path, head }); items.push(ConversationItem { path, head });
} }
} }

View File

@@ -107,6 +107,7 @@ impl RolloutRecorder {
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z" "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
); );
let timestamp = timestamp let timestamp = timestamp
.to_offset(time::UtcOffset::UTC)
.format(timestamp_format) .format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;

View File

@@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager; use crate::file_search::FileSearchManager;
use crate::pager_overlay::Overlay; use crate::pager_overlay::Overlay;
use crate::resume_picker::ResumeSelection;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent; use crate::tui::TuiEvent;
use codex_ansi_escape::ansi_escape_line; use codex_ansi_escape::ansi_escape_line;
@@ -12,6 +13,7 @@ use codex_core::ConversationManager;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsage;
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
@@ -61,6 +63,7 @@ impl App {
config: Config, config: Config,
initial_prompt: Option<String>, initial_prompt: Option<String>,
initial_images: Vec<PathBuf>, initial_images: Vec<PathBuf>,
resume_selection: ResumeSelection,
) -> Result<TokenUsage> { ) -> Result<TokenUsage> {
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel(); let (app_event_tx, mut app_event_rx) = unbounded_channel();
@@ -70,15 +73,44 @@ impl App {
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
let chat_widget = ChatWidget::new( let chat_widget = match resume_selection {
config.clone(), ResumeSelection::StartFresh | ResumeSelection::Exit => {
conversation_manager.clone(), let init = crate::chatwidget::ChatWidgetInit {
tui.frame_requester(), config: config.clone(),
app_event_tx.clone(), frame_requester: tui.frame_requester(),
initial_prompt, app_event_tx: app_event_tx.clone(),
initial_images, initial_prompt: initial_prompt.clone(),
enhanced_keys_supported, initial_images: initial_images.clone(),
); enhanced_keys_supported,
};
ChatWidget::new(init, conversation_manager.clone())
}
ResumeSelection::Resume(path) => {
let resumed = conversation_manager
.resume_conversation_from_rollout(
config.clone(),
path.clone(),
auth_manager.clone(),
)
.await
.wrap_err_with(|| {
format!("Failed to resume session from {}", path.display())
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_prompt: initial_prompt.clone(),
initial_images: initial_images.clone(),
enhanced_keys_supported,
};
ChatWidget::new_from_existing(
init,
resumed.conversation,
resumed.session_configured,
)
}
};
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
@@ -168,15 +200,15 @@ impl App {
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
match event { match event {
AppEvent::NewSession => { AppEvent::NewSession => {
self.chat_widget = ChatWidget::new( let init = crate::chatwidget::ChatWidgetInit {
self.config.clone(), config: self.config.clone(),
self.server.clone(), frame_requester: tui.frame_requester(),
tui.frame_requester(), app_event_tx: self.app_event_tx.clone(),
self.app_event_tx.clone(), initial_prompt: None,
None, initial_images: Vec::new(),
Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported,
self.enhanced_keys_supported, };
); self.chat_widget = ChatWidget::new(init, self.server.clone());
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
AppEvent::InsertHistoryCell(cell) => { AppEvent::InsertHistoryCell(cell) => {

View File

@@ -319,14 +319,16 @@ impl App {
) { ) {
let conv = new_conv.conversation; let conv = new_conv.conversation;
let session_configured = new_conv.session_configured; let session_configured = new_conv.session_configured;
self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing( let init = crate::chatwidget::ChatWidgetInit {
cfg, config: cfg,
conv, frame_requester: tui.frame_requester(),
session_configured, app_event_tx: self.app_event_tx.clone(),
tui.frame_requester(), initial_prompt: None,
self.app_event_tx.clone(), initial_images: Vec::new(),
self.enhanced_keys_supported, enhanced_keys_supported: self.enhanced_keys_supported,
); };
self.chat_widget =
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
// Trim transcript up to the selected user message and re-render it. // Trim transcript up to the selected user message and re-render it.
self.trim_transcript_for_backtrack(drop_count); self.trim_transcript_for_backtrack(drop_count);
self.render_transcript_once(tui); self.render_transcript_once(tui);

View File

@@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::InputItem; use codex_core::protocol::InputItem;
use codex_core::protocol::InputMessageKind;
use codex_core::protocol::ListCustomPromptsResponseEvent; use codex_core::protocol::ListCustomPromptsResponseEvent;
use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent;
use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallBeginEvent;
@@ -30,6 +31,7 @@ use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsage;
use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::UserMessageEvent;
use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent; use codex_core::protocol::WebSearchEndEvent;
use codex_protocol::parse_command::ParsedCommand; use codex_protocol::parse_command::ParsedCommand;
@@ -89,6 +91,16 @@ struct RunningCommand {
parsed_cmd: Vec<ParsedCommand>, parsed_cmd: Vec<ParsedCommand>,
} }
/// Common initialization parameters shared by all `ChatWidget` constructors.
pub(crate) struct ChatWidgetInit {
pub(crate) config: Config,
pub(crate) frame_requester: FrameRequester,
pub(crate) app_event_tx: AppEventSender,
pub(crate) initial_prompt: Option<String>,
pub(crate) initial_images: Vec<PathBuf>,
pub(crate) enhanced_keys_supported: bool,
}
pub(crate) struct ChatWidget { pub(crate) struct ChatWidget {
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>, codex_op_tx: UnboundedSender<Op>,
@@ -112,6 +124,9 @@ pub(crate) struct ChatWidget {
frame_requester: FrameRequester, frame_requester: FrameRequester,
// Whether to include the initial welcome banner on session configured // Whether to include the initial welcome banner on session configured
show_welcome_banner: bool, show_welcome_banner: bool,
// When resuming an existing session (selected via resume picker), avoid an
// immediate redraw on SessionConfigured to prevent a gratuitous UI flicker.
suppress_session_configured_redraw: bool,
// User messages queued while a turn is in progress // User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>, queued_user_messages: VecDeque<UserMessage>,
} }
@@ -148,6 +163,10 @@ impl ChatWidget {
self.bottom_pane self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count); .set_history_metadata(event.history_log_id, event.history_entry_count);
self.session_id = Some(event.session_id); self.session_id = Some(event.session_id);
let initial_messages = event.initial_messages.clone();
if let Some(messages) = initial_messages {
self.replay_initial_messages(messages);
}
self.add_to_history(history_cell::new_session_info( self.add_to_history(history_cell::new_session_info(
&self.config, &self.config,
event, event,
@@ -158,7 +177,9 @@ impl ChatWidget {
if let Some(user_message) = self.initial_user_message.take() { if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message); self.submit_user_message(user_message);
} }
self.request_redraw(); if !self.suppress_session_configured_redraw {
self.request_redraw();
}
} }
fn on_agent_message(&mut self, message: String) { fn on_agent_message(&mut self, message: String) {
@@ -602,14 +623,17 @@ impl ChatWidget {
} }
pub(crate) fn new( pub(crate) fn new(
config: Config, common: ChatWidgetInit,
conversation_manager: Arc<ConversationManager>, conversation_manager: Arc<ConversationManager>,
frame_requester: FrameRequester,
app_event_tx: AppEventSender,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
) -> Self { ) -> Self {
let ChatWidgetInit {
config,
frame_requester,
app_event_tx,
initial_prompt,
initial_images,
enhanced_keys_supported,
} = common;
let mut rng = rand::rng(); let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager);
@@ -643,18 +667,24 @@ impl ChatWidget {
session_id: None, session_id: None,
queued_user_messages: VecDeque::new(), queued_user_messages: VecDeque::new(),
show_welcome_banner: true, show_welcome_banner: true,
suppress_session_configured_redraw: false,
} }
} }
/// Create a ChatWidget attached to an existing conversation (e.g., a fork). /// Create a ChatWidget attached to an existing conversation (e.g., a fork).
pub(crate) fn new_from_existing( pub(crate) fn new_from_existing(
config: Config, common: ChatWidgetInit,
conversation: std::sync::Arc<codex_core::CodexConversation>, conversation: std::sync::Arc<codex_core::CodexConversation>,
session_configured: codex_core::protocol::SessionConfiguredEvent, session_configured: codex_core::protocol::SessionConfiguredEvent,
frame_requester: FrameRequester,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
) -> Self { ) -> Self {
let ChatWidgetInit {
config,
frame_requester,
app_event_tx,
initial_prompt,
initial_images,
enhanced_keys_supported,
} = common;
let mut rng = rand::rng(); let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
@@ -675,7 +705,10 @@ impl ChatWidget {
}), }),
active_exec_cell: None, active_exec_cell: None,
config: config.clone(), config: config.clone(),
initial_user_message: None, initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
),
total_token_usage: TokenUsage::default(), total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(),
stream: StreamController::new(config), stream: StreamController::new(config),
@@ -687,6 +720,7 @@ impl ChatWidget {
session_id: None, session_id: None,
queued_user_messages: VecDeque::new(), queued_user_messages: VecDeque::new(),
show_welcome_banner: false, show_welcome_banner: false,
suppress_session_configured_redraw: true,
} }
} }
@@ -950,9 +984,32 @@ impl ChatWidget {
} }
} }
/// Replay a subset of initial events into the UI to seed the transcript when
/// resuming an existing session. This approximates the live event flow and
/// is intentionally conservative: only safe-to-replay items are rendered to
/// avoid triggering side effects. Event ids are passed as `None` to
/// distinguish replayed events from live ones.
fn replay_initial_messages(&mut self, events: Vec<EventMsg>) {
for msg in events {
if matches!(msg, EventMsg::SessionConfigured(_)) {
continue;
}
// `id: None` indicates a synthetic/fake id coming from replay.
self.dispatch_event_msg(None, msg, true);
}
}
pub(crate) fn handle_codex_event(&mut self, event: Event) { pub(crate) fn handle_codex_event(&mut self, event: Event) {
let Event { id, msg } = event; let Event { id, msg } = event;
self.dispatch_event_msg(Some(id), msg, false);
}
/// Dispatch a protocol `EventMsg` to the appropriate handler.
///
/// `id` is `Some` for live events and `None` for replayed events from
/// `replay_initial_messages()`. Callers should treat `None` as a "fake" id
/// that must not be used to correlate follow-up actions.
fn dispatch_event_msg(&mut self, id: Option<String>, msg: EventMsg, from_replay: bool) {
match msg { match msg {
EventMsg::AgentMessageDelta(_) EventMsg::AgentMessageDelta(_)
| EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningDelta(_)
@@ -990,8 +1047,13 @@ impl ChatWidget {
} }
}, },
EventMsg::PlanUpdate(update) => self.on_plan_update(update), EventMsg::PlanUpdate(update) => self.on_plan_update(update),
EventMsg::ExecApprovalRequest(ev) => self.on_exec_approval_request(id, ev), EventMsg::ExecApprovalRequest(ev) => {
EventMsg::ApplyPatchApprovalRequest(ev) => self.on_apply_patch_approval_request(id, ev), // For replayed events, synthesize an empty id (these should not occur).
self.on_exec_approval_request(id.clone().unwrap_or_default(), ev)
}
EventMsg::ApplyPatchApprovalRequest(ev) => {
self.on_apply_patch_approval_request(id.clone().unwrap_or_default(), ev)
}
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
@@ -1010,15 +1072,33 @@ impl ChatWidget {
self.on_background_event(message) self.on_background_event(message)
} }
EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message), EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message),
EventMsg::UserMessage(..) => {} EventMsg::UserMessage(ev) => {
if from_replay {
self.on_user_message_event(ev);
}
}
EventMsg::ConversationHistory(ev) => { EventMsg::ConversationHistory(ev) => {
// Forward to App so it can process backtrack flows.
self.app_event_tx self.app_event_tx
.send(crate::app_event::AppEvent::ConversationHistory(ev)); .send(crate::app_event::AppEvent::ConversationHistory(ev));
} }
} }
} }
fn on_user_message_event(&mut self, event: UserMessageEvent) {
match event.kind {
Some(InputMessageKind::EnvironmentContext)
| Some(InputMessageKind::UserInstructions) => {
// Skip XMLwrapped context blocks in the transcript.
}
Some(InputMessageKind::Plain) | None => {
let message = event.message.trim();
if !message.is_empty() {
self.add_to_history(history_cell::new_user_prompt(message.to_string()));
}
}
}
}
fn request_redraw(&mut self) { fn request_redraw(&mut self) {
self.frame_requester.schedule_frame(); self.frame_requester.schedule_frame();
} }

View File

@@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange; use codex_core::protocol::FileChange;
use codex_core::protocol::InputMessageKind;
use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::StreamErrorEvent;
@@ -34,6 +35,7 @@ use std::io::BufRead;
use std::io::BufReader; use std::io::BufReader;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::unbounded_channel;
use uuid::Uuid;
fn test_config() -> Config { fn test_config() -> Config {
// Use base defaults to avoid depending on host state. // Use base defaults to avoid depending on host state.
@@ -126,6 +128,53 @@ fn final_answer_without_newline_is_flushed_immediately() {
); );
} }
#[test]
fn resumed_initial_messages_render_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: Uuid::nil(),
model: "test-model".to_string(),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![
EventMsg::UserMessage(codex_core::protocol::UserMessageEvent {
message: "hello from user".to_string(),
kind: Some(InputMessageKind::Plain),
}),
EventMsg::AgentMessage(AgentMessageEvent {
message: "assistant reply".to_string(),
}),
]),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
let cells = drain_insert_history(&mut rx);
let mut merged_lines = Vec::new();
for lines in cells {
let text = lines
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.clone())
.collect::<String>();
merged_lines.push(text);
}
let text_blob = merged_lines.join("\n");
assert!(
text_blob.contains("hello from user"),
"expected replayed user message",
);
assert!(
text_blob.contains("assistant reply"),
"expected replayed agent message",
);
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn helpers_are_available_and_do_not_panic() { async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -134,15 +183,15 @@ async fn helpers_are_available_and_do_not_panic() {
let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key(
"test", "test",
))); )));
let mut w = ChatWidget::new( let init = ChatWidgetInit {
cfg, config: cfg,
conversation_manager, frame_requester: crate::tui::FrameRequester::test_dummy(),
crate::tui::FrameRequester::test_dummy(), app_event_tx: tx,
tx, initial_prompt: None,
None, initial_images: Vec::new(),
Vec::new(), enhanced_keys_supported: false,
false, };
); let mut w = ChatWidget::new(init, conversation_manager);
// Basic construction sanity. // Basic construction sanity.
let _ = &mut w; let _ = &mut w;
} }
@@ -184,6 +233,7 @@ fn make_chatwidget_manual() -> (
frame_requester: crate::tui::FrameRequester::test_dummy(), frame_requester: crate::tui::FrameRequester::test_dummy(),
show_welcome_banner: true, show_welcome_banner: true,
queued_user_messages: std::collections::VecDeque::new(), queued_user_messages: std::collections::VecDeque::new(),
suppress_session_configured_redraw: false,
}; };
(widget, rx, op_rx) (widget, rx, op_rx)
} }

View File

@@ -13,6 +13,30 @@ pub struct Cli {
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
pub images: Vec<PathBuf>, pub images: Vec<PathBuf>,
/// Open an interactive picker to resume a previous session recorded on disk
/// instead of starting a new one.
///
/// Notes:
/// - Mutually exclusive with `--continue`.
/// - The picker displays recent sessions and a preview of the first real user
/// message to help you select the right one.
#[arg(long = "resume", default_value_t = false, conflicts_with = "continue")]
pub resume: bool,
/// Continue the most recent conversation without showing the picker.
///
/// Notes:
/// - Mutually exclusive with `--resume`.
/// - If no recorded sessions are found, this behaves like starting fresh.
/// - Equivalent to picking the newest item in the resume picker.
#[arg(
id = "continue",
long = "continue",
default_value_t = false,
conflicts_with = "resume"
)]
pub r#continue: bool,
/// Model the agent should use. /// Model the agent should use.
#[arg(long, short = 'm')] #[arg(long, short = 'm')]
pub model: Option<String>, pub model: Option<String>,

View File

@@ -7,6 +7,7 @@ use app::App;
use codex_core::AuthManager; use codex_core::AuthManager;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::CodexAuth; use codex_core::CodexAuth;
use codex_core::RolloutRecorder;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::config::ConfigOverrides; use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml; use codex_core::config::ConfigToml;
@@ -47,6 +48,7 @@ mod markdown_stream;
pub mod onboarding; pub mod onboarding;
mod pager_overlay; mod pager_overlay;
mod render; mod render;
mod resume_picker;
mod session_log; mod session_log;
mod shimmer; mod shimmer;
mod slash_command; mod slash_command;
@@ -299,7 +301,13 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled. // Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&config); session_log::maybe_init(&config);
let Cli { prompt, images, .. } = cli; let Cli {
prompt,
images,
resume,
r#continue,
..
} = cli;
let auth_manager = AuthManager::shared( let auth_manager = AuthManager::shared(
config.codex_home.clone(), config.codex_home.clone(),
@@ -327,7 +335,37 @@ async fn run_ratatui_app(
} }
} }
let app_result = App::run(&mut tui, auth_manager, config, prompt, images).await; let resume_selection = if r#continue {
match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
Ok(page) => page
.items
.first()
.map(|it| resume_picker::ResumeSelection::Resume(it.path.clone()))
.unwrap_or(resume_picker::ResumeSelection::StartFresh),
Err(_) => resume_picker::ResumeSelection::StartFresh,
}
} else if resume {
match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? {
resume_picker::ResumeSelection::Exit => {
restore();
session_log::log_session_end();
return Ok(codex_core::protocol::TokenUsage::default());
}
other => other,
}
} else {
resume_picker::ResumeSelection::StartFresh
};
let app_result = App::run(
&mut tui,
auth_manager,
config,
prompt,
images,
resume_selection,
)
.await;
restore(); restore();
// Mark the end of the recorded session. // Mark the end of the recorded session.

View File

@@ -0,0 +1,504 @@
use std::path::Path;
use std::path::PathBuf;
use chrono::DateTime;
use chrono::TimeZone;
use chrono::Utc;
use codex_core::ConversationItem;
use codex_core::ConversationsPage;
use codex_core::Cursor;
use codex_core::RolloutRecorder;
use codex_core::protocol::InputMessageKind;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use tokio_stream::StreamExt;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
const PAGE_SIZE: usize = 25;
#[derive(Debug, Clone)]
pub enum ResumeSelection {
StartFresh,
Resume(PathBuf),
Exit,
}
/// 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.
pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<ResumeSelection> {
let alt = AltScreenGuard::enter(tui);
let mut state = PickerState::new(codex_home.to_path_buf(), alt.tui.frame_requester());
state.load_page(None).await?;
state.request_frame();
let mut events = alt.tui.event_stream();
while let Some(ev) = events.next().await {
match ev {
TuiEvent::Key(key) => {
if matches!(key.kind, KeyEventKind::Release) {
continue;
}
if let Some(sel) = state.handle_key(key).await? {
return Ok(sel);
}
}
TuiEvent::Draw => {
draw_picker(alt.tui, &state)?;
}
// Ignore paste and attach-image in picker
_ => {}
}
}
// Fallback treat as cancel/new
Ok(ResumeSelection::StartFresh)
}
/// RAII guard that ensures we leave the alt-screen on scope exit.
struct AltScreenGuard<'a> {
tui: &'a mut Tui,
}
impl<'a> AltScreenGuard<'a> {
fn enter(tui: &'a mut Tui) -> Self {
let _ = tui.enter_alt_screen();
Self { tui }
}
}
impl Drop for AltScreenGuard<'_> {
fn drop(&mut self) {
let _ = self.tui.leave_alt_screen();
}
}
struct PickerState {
codex_home: PathBuf,
requester: FrameRequester,
// pagination
pagination: Pagination,
// data
all_rows: Vec<Row>, // unfiltered rows for current page
filtered_rows: Vec<Row>,
selected: usize,
// search
query: String,
}
#[derive(Debug, Clone)]
struct Pagination {
current_anchor: Option<Cursor>,
backstack: Vec<Option<Cursor>>, // track previous anchors for ←/a
next_cursor: Option<Cursor>,
page_index: usize,
}
#[derive(Clone)]
struct Row {
path: PathBuf,
preview: String,
ts: Option<DateTime<Utc>>,
}
impl PickerState {
fn new(codex_home: PathBuf, requester: FrameRequester) -> Self {
Self {
codex_home,
requester,
pagination: Pagination {
current_anchor: None,
backstack: vec![None],
next_cursor: None,
page_index: 0,
},
all_rows: Vec::new(),
filtered_rows: Vec::new(),
selected: 0,
query: String::new(),
}
}
fn request_frame(&self) {
self.requester.schedule_frame();
}
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<ResumeSelection>> {
match key.code {
KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)),
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
return Ok(Some(ResumeSelection::Exit));
}
KeyCode::Enter => {
if let Some(row) = self.filtered_rows.get(self.selected) {
return Ok(Some(ResumeSelection::Resume(row.path.clone())));
}
}
KeyCode::Up => {
if self.selected > 0 {
self.selected -= 1;
}
self.request_frame();
}
KeyCode::Down => {
if self.selected + 1 < self.filtered_rows.len() {
self.selected += 1;
}
self.request_frame();
}
KeyCode::Left | KeyCode::Char('a') => {
self.prev_page().await?;
}
KeyCode::Right | KeyCode::Char('d') => {
self.next_page().await?;
}
KeyCode::Backspace => {
self.query.pop();
self.apply_filter();
}
KeyCode::Char(c) => {
// basic text input for search
if !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
&& !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
{
self.query.push(c);
self.apply_filter();
}
}
_ => {}
}
Ok(None)
}
async fn prev_page(&mut self) -> Result<()> {
if self.pagination.page_index == 0 {
return Ok(());
}
// current_anchor points to the page we just loaded; backstack[page_index-1] is the anchor to reload
if self.pagination.page_index > 0 {
self.pagination.page_index -= 1;
let anchor = self
.pagination
.backstack
.get(self.pagination.page_index)
.cloned()
.flatten();
self.pagination.current_anchor = anchor.clone();
self.load_page(anchor.as_ref()).await?;
}
Ok(())
}
async fn next_page(&mut self) -> Result<()> {
if let Some(next) = self.pagination.next_cursor.clone() {
// Record the anchor for the page we are moving to at index new_index
let new_index = self.pagination.page_index + 1;
if self.pagination.backstack.len() <= new_index {
self.pagination.backstack.resize(new_index + 1, None);
}
self.pagination.backstack[new_index] = Some(next.clone());
self.pagination.current_anchor = Some(next.clone());
self.pagination.page_index = new_index;
let anchor = self.pagination.current_anchor.clone();
self.load_page(anchor.as_ref()).await?;
}
Ok(())
}
async fn load_page(&mut self, anchor: Option<&Cursor>) -> Result<()> {
let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, anchor).await?;
self.pagination.next_cursor = page.next_cursor.clone();
self.all_rows = to_rows(page);
self.apply_filter();
// reset selection on new page
self.selected = 0;
Ok(())
}
fn apply_filter(&mut self) {
if self.query.is_empty() {
self.filtered_rows = self.all_rows.clone();
} else {
let q = self.query.to_lowercase();
self.filtered_rows = self
.all_rows
.iter()
.filter(|r| r.preview.to_lowercase().contains(&q))
.cloned()
.collect();
}
if self.selected >= self.filtered_rows.len() {
self.selected = self.filtered_rows.len().saturating_sub(1);
}
self.request_frame();
}
}
fn to_rows(page: ConversationsPage) -> Vec<Row> {
use std::cmp::Reverse;
let mut rows: Vec<Row> = page
.items
.into_iter()
.filter_map(|it| head_to_row(&it))
.collect();
// Ensure newest-first ordering within the page by timestamp when available.
let epoch = Utc.timestamp_opt(0, 0).single().unwrap_or_else(Utc::now);
rows.sort_by_key(|r| Reverse(r.ts.unwrap_or(epoch)));
rows
}
fn head_to_row(item: &ConversationItem) -> Option<Row> {
let mut ts: Option<DateTime<Utc>> = None;
if let Some(first) = item.head.first()
&& let Some(t) = first.get("timestamp").and_then(|v| v.as_str())
&& let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t)
{
ts = Some(parsed.with_timezone(&Utc));
}
let preview = find_first_user_text(&item.head)?;
let preview = preview.trim().to_string();
if preview.is_empty() {
return None;
}
Some(Row {
path: item.path.clone(),
preview,
ts,
})
}
/// Return the first plain user text from the JSONL `head` of a rollout.
///
/// Strategy: scan for the first `{ type: "message", role: "user" }` entry and
/// then return the first `content` item where `{ type: "input_text" }` that is
/// classified as `InputMessageKind::Plain` (i.e., not wrapped in
/// `<user_instructions>` or `<environment_context>` tags).
fn find_first_user_text(head: &[serde_json::Value]) -> Option<String> {
for v in head.iter() {
let t = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
if t != "message" {
continue;
}
if v.get("role").and_then(|x| x.as_str()) != Some("user") {
continue;
}
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
for c in arr.iter() {
if let (Some("input_text"), Some(txt)) =
(c.get("type").and_then(|t| t.as_str()), c.get("text"))
&& let Some(s) = txt.as_str()
{
// Skip XML-wrapped user_instructions/environment_context blocks and
// return the first plain user text we find.
if matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain) {
return Some(s.to_string());
}
}
}
}
}
None
}
fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
// Render full-screen overlay
let height = tui.terminal.size()?.height;
tui.draw(height, |frame| {
let area = frame.area();
let [header, search, list, hint] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(area.height.saturating_sub(3)),
Constraint::Length(1),
])
.areas(area);
// Header
frame.render_widget_ref(
Line::from(vec!["Resume a previous session".bold().cyan()]),
header,
);
// Search line
let q = if state.query.is_empty() {
"Type to search".dim().to_string()
} else {
format!("Search: {}", state.query)
};
frame.render_widget_ref(Line::from(q), search);
// List
render_list(frame, list, state);
// Hint line
let hint_line: Line = vec![
"Enter".bold(),
" to resume ".into(),
"Esc".bold(),
" to start new ".into(),
"Ctrl+C".into(),
" to quit ".dim(),
"←/a".into(),
" prev ".dim(),
"→/d".into(),
" next".dim(),
]
.into();
frame.render_widget_ref(hint_line, hint);
})
}
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
let rows = &state.filtered_rows;
if rows.is_empty() {
frame.render_widget_ref(Line::from("No sessions found".italic().dim()), area);
return;
}
// Compute how many rows fit (1 line per item)
let capacity = area.height as usize;
let start = state.selected.saturating_sub(capacity.saturating_sub(1));
let visible = &rows[start..rows.len().min(start + capacity)];
let mut y = area.y;
for (idx, row) in visible.iter().enumerate() {
let is_sel = start + idx == state.selected;
let marker = if is_sel { "> ".bold() } else { " ".into() };
let ts = row
.ts
.map(human_time_ago)
.unwrap_or_else(|| "".to_string())
.dim();
let max_cols = area.width.saturating_sub(6) as usize;
let preview = truncate_text(&row.preview, max_cols);
let line: Line = vec![marker, ts, " ".into(), preview.into()].into();
let rect = Rect::new(area.x, y, area.width, 1);
frame.render_widget_ref(line, rect);
y = y.saturating_add(1);
}
}
fn human_time_ago(ts: DateTime<Utc>) -> String {
let now = Utc::now();
let delta = now - ts;
let secs = delta.num_seconds();
if secs < 60 {
let n = secs.max(0);
if n == 1 {
format!("{n} second ago")
} else {
format!("{n} seconds ago")
}
} else if secs < 60 * 60 {
let m = secs / 60;
if m == 1 {
format!("{m} minute ago")
} else {
format!("{m} minutes ago")
}
} else if secs < 60 * 60 * 24 {
let h = secs / 3600;
if h == 1 {
format!("{h} hour ago")
} else {
format!("{h} hours ago")
}
} else {
let d = secs / (60 * 60 * 24);
if d == 1 {
format!("{d} day ago")
} else {
format!("{d} days ago")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn head_with_ts_and_user_text(ts: &str, texts: &[&str]) -> Vec<serde_json::Value> {
vec![
json!({ "timestamp": ts }),
json!({
"type": "message",
"role": "user",
"content": texts
.iter()
.map(|t| json!({ "type": "input_text", "text": *t }))
.collect::<Vec<_>>()
}),
]
}
#[test]
fn skips_user_instructions_and_env_context() {
let head = vec![
json!({ "timestamp": "2025-01-01T00:00:00Z" }),
json!({
"type": "message",
"role": "user",
"content": [
{ "type": "input_text", "text": "<user_instructions>hi</user_instructions>" }
]
}),
json!({
"type": "message",
"role": "user",
"content": [
{ "type": "input_text", "text": "<environment_context>cwd</environment_context>" }
]
}),
json!({
"type": "message",
"role": "user",
"content": [ { "type": "input_text", "text": "real question" } ]
}),
];
let first = find_first_user_text(&head);
assert_eq!(first.as_deref(), Some("real question"));
}
#[test]
fn to_rows_sorts_descending_by_timestamp() {
// Construct two items with different timestamps and real user text.
let a = ConversationItem {
path: PathBuf::from("/tmp/a.jsonl"),
head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]),
};
let b = ConversationItem {
path: PathBuf::from("/tmp/b.jsonl"),
head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]),
};
let rows = to_rows(ConversationsPage {
items: vec![a, b],
next_cursor: None,
num_scanned_files: 0,
reached_scan_cap: false,
});
assert_eq!(rows.len(), 2);
// Expect the newer timestamp (B) first
assert!(rows[0].preview.contains('B'));
assert!(rows[1].preview.contains('A'));
}
}

View File

@@ -10,6 +10,18 @@
Key flags: `--model/-m`, `--ask-for-approval/-a`. Key flags: `--model/-m`, `--ask-for-approval/-a`.
Resume options:
- `--resume`: open an interactive picker of recent sessions (shows a preview of the first real user message). Conflicts with `--continue`.
- `--continue`: resume the most recent session without showing the picker (falls back to starting fresh if none exist). Conflicts with `--resume`.
Examples:
```shell
codex --resume
codex --continue
```
### Running with a prompt as input ### Running with a prompt as input
You can also run Codex CLI with a prompt as input: You can also run Codex CLI with a prompt as input: