add slash resume (#7302)
`codex resume` isn't that discoverable. Adding it to the slash commands can help
This commit is contained in:
parent
3ef76ff29d
commit
2ad980abf4
12 changed files with 346 additions and 14 deletions
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
|
|
@ -1625,6 +1625,7 @@ dependencies = [
|
|||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
"url",
|
||||
"uuid",
|
||||
"vt100",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -110,3 +110,4 @@ pretty_assertions = { workspace = true }
|
|||
rand = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
vt100 = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -489,6 +489,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<Line<'static>> =
|
||||
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<dyn HistoryCell> = cell.into();
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -2376,6 +2376,62 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_resume_for_res_ui() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 2385
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› /res "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" /resume resume a saved chat "
|
||||
|
|
@ -1482,6 +1482,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() {
|
||||
|
|
|
|||
|
|
@ -1185,6 +1185,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();
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
@ -1260,6 +1263,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<Utc>, 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(|_| {});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue