diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d6d01045b4b..36eb7fd3cd2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -54,6 +54,7 @@ use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; +use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; @@ -82,6 +83,25 @@ struct AttachedImage { path: PathBuf, } +#[derive(Debug)] +struct StashedDraft { + text: String, + pending_pastes: Vec<(String, String)>, + attached_images: Vec, +} + +impl StashedDraft { + fn preview(&self) -> String { + let first_line = self + .text + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or_default() + .trim(); + truncate_text(first_line, 20) + } +} + enum PromptSelectionMode { Completion, Submit, @@ -104,6 +124,7 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, + stashed_draft: Option, large_paste_counters: HashMap, has_focus: bool, attached_images: Vec, @@ -154,6 +175,7 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), + stashed_draft: None, large_paste_counters: HashMap::new(), has_focus: has_input_focus, attached_images: Vec::new(), @@ -500,6 +522,42 @@ impl ChatComposer { result } + fn stash_draft(&mut self) -> bool { + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + + if self.is_empty() { + return false; + } + + self.stashed_draft = Some(StashedDraft { + text: self.textarea.text().to_string(), + pending_pastes: std::mem::take(&mut self.pending_pastes), + attached_images: std::mem::take(&mut self.attached_images), + }); + + self.set_text_content(String::new()); + self.active_popup = ActivePopup::None; + true + } + + pub(crate) fn restore_stashed_draft_if_possible(&mut self) -> bool { + if !self.is_empty() { + return false; + } + + let Some(stashed) = self.stashed_draft.take() else { + return false; + }; + + // Reuse attachment rebuild logic so placeholders become elements again. + self.attached_images = stashed.attached_images; + self.apply_external_edit(stashed.text); + self.pending_pastes = stashed.pending_pastes; + true + } + /// Return true if either the slash-command popup or the file-search popup is active. pub(crate) fn popup_active(&self) -> bool { !matches!(self.active_popup, ActivePopup::None) @@ -1125,6 +1183,12 @@ impl ChatComposer { self.footer_mode = reset_mode_after_activity(self.footer_mode); } match key_event { + KeyEvent { + code: KeyCode::Char('s'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => (InputResult::None, self.stash_draft()), KeyEvent { code: KeyCode::Char('d'), modifiers: crossterm::event::KeyModifiers::CONTROL, @@ -1621,6 +1685,7 @@ impl ChatComposer { } fn footer_props(&self) -> FooterProps { + let stashed_draft_preview = self.stashed_draft.as_ref().map(StashedDraft::preview); FooterProps { mode: self.footer_mode(), esc_backtrack_hint: self.esc_backtrack_hint, @@ -1628,6 +1693,7 @@ impl ChatComposer { is_task_running: self.is_task_running, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, + stashed_draft_preview, } } @@ -1919,7 +1985,7 @@ impl Renderable for ChatComposer { let footer_props = self.footer_props(); let custom_height = self.custom_footer_height(); let footer_hint_height = - custom_height.unwrap_or_else(|| footer_height(footer_props)); + custom_height.unwrap_or_else(|| footer_height(footer_props.clone())); let footer_spacing = Self::footer_spacing(footer_hint_height); let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { let [_, hint_rect] = Layout::vertical([ diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index c3f2da0e350..54a61561c6c 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -14,7 +14,7 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, @@ -22,6 +22,7 @@ pub(crate) struct FooterProps { pub(crate) is_task_running: bool, pub(crate) context_window_percent: Option, pub(crate) context_window_used_tokens: Option, + pub(crate) stashed_draft_preview: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -90,10 +91,18 @@ fn footer_lines(props: FooterProps) -> Vec> { props.context_window_used_tokens, ); line.push_span(" · ".dim()); - line.extend(vec![ - key_hint::plain(KeyCode::Char('?')).into(), - " for shortcuts".dim(), - ]); + if let Some(preview) = props.stashed_draft_preview { + line.extend(vec![ + "STASHED:".cyan(), + " ".into(), + Span::from(format!("\"{preview}\"")).dim(), + ]); + } else { + line.extend(vec![ + key_hint::plain(KeyCode::Char('?')).into(), + " for shortcuts".dim(), + ]); + } vec![line] } FooterMode::ShortcutOverlay => { @@ -110,10 +119,21 @@ fn footer_lines(props: FooterProps) -> Vec> { shortcut_overlay_lines(state) } FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => vec![context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - )], + FooterMode::ContextOnly => { + let mut line = context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + ); + if let Some(preview) = props.stashed_draft_preview { + line.push_span(" · ".dim()); + line.extend(vec![ + "STASHED:".cyan(), + " ".into(), + Span::from(format!("\"{preview}\"")).dim(), + ]); + } + vec![line] + } } } @@ -163,6 +183,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); let mut external_editor = Line::from(""); + let mut stash_draft = Line::from(""); let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); @@ -175,6 +196,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, ShortcutId::ExternalEditor => external_editor = text, + ShortcutId::StashDraft => stash_draft = text, ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, @@ -188,9 +210,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { file_paths, paste_image, external_editor, + stash_draft, edit_previous, quit, - Line::from(""), show_transcript, ]; @@ -265,6 +287,7 @@ enum ShortcutId { FilePaths, PasteImage, ExternalEditor, + StashDraft, EditPrevious, Quit, ShowTranscript, @@ -394,6 +417,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to edit in external editor", }, + ShortcutDescriptor { + id: ShortcutId::StashDraft, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('s')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to stash prompt", + }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { @@ -431,7 +463,7 @@ mod tests { use ratatui::backend::TestBackend; fn snapshot_footer(name: &str, props: FooterProps) { - let height = footer_height(props).max(1); + let height = footer_height(props.clone()).max(1); let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); terminal .draw(|f| { @@ -453,6 +485,7 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + stashed_draft_preview: None, }, ); @@ -465,6 +498,7 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + stashed_draft_preview: None, }, ); @@ -477,6 +511,7 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + stashed_draft_preview: None, }, ); @@ -489,6 +524,7 @@ mod tests { is_task_running: true, context_window_percent: None, context_window_used_tokens: None, + stashed_draft_preview: None, }, ); @@ -501,6 +537,7 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + stashed_draft_preview: None, }, ); @@ -513,6 +550,7 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + stashed_draft_preview: None, }, ); @@ -525,6 +563,7 @@ mod tests { is_task_running: true, context_window_percent: Some(72), context_window_used_tokens: None, + stashed_draft_preview: None, }, ); @@ -537,6 +576,7 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: Some(123_456), + stashed_draft_preview: None, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7634f699aa0..63c352468de 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -264,6 +264,12 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn restore_stashed_draft_if_possible(&mut self) { + if self.composer.restore_stashed_draft_if_possible() { + self.request_redraw(); + } + } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { self.composer.clear_for_ctrl_c(); self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 7d05a922370..fe29744dcc9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2190 expression: terminal.backend() --- " " @@ -12,6 +13,6 @@ expression: terminal.backend() " " " / for commands shift + enter for newline " " @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " +" ctrl + g to edit in external editor ctrl + s to stash prompt " +" esc again to edit previous message ctrl + c to exit " " ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 445fa44484c..69e43f31c52 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -1,9 +1,10 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 474 expression: terminal.backend() --- " / for commands shift + enter for newline " " @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " +" ctrl + g to edit in external editor ctrl + s to stash prompt " +" esc again to edit previous message ctrl + c to exit " " ctrl + t to view transcript " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 41ab0087ea7..baf9dd2a040 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -577,6 +577,7 @@ impl ChatWidget { self.running_commands.clear(); self.suppressed_exec_calls.clear(); self.last_unified_wait = None; + self.bottom_pane.restore_stashed_draft_if_possible(); self.request_redraw(); // If there is a queued user message, send exactly one now to begin the next turn. @@ -709,6 +710,7 @@ impl ChatWidget { self.running_commands.clear(); self.suppressed_exec_calls.clear(); self.last_unified_wait = None; + self.bottom_pane.restore_stashed_draft_if_possible(); self.stream_controller = None; self.maybe_show_pending_rate_limit_prompt(); } @@ -1629,12 +1631,16 @@ impl ChatWidget { _ => { match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted(text) => { + let was_running = self.bottom_pane.is_task_running(); // If a task is running, queue the user input to be sent after the turn completes. let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), }; self.queue_user_message(user_message); + if !was_running { + self.bottom_pane.restore_stashed_draft_if_possible(); + } } InputResult::Command(cmd) => { self.dispatch_command(cmd); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a0ff8d42e9d..f461b871144 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1120,6 +1120,98 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { ); } +#[tokio::test] +async fn ctrl_s_noops_when_composer_empty() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.on_task_started(); + chat.on_task_complete(None); + assert!(chat.bottom_pane.composer_text().is_empty()); +} + +#[tokio::test] +async fn ctrl_s_stashes_and_restores_after_next_submission_when_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.set_composer_text("draft A".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.bottom_pane.set_composer_text("send now".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "draft A"); +} + +#[tokio::test] +async fn ctrl_s_stashes_during_task_and_restores_after_task_complete() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.on_task_started(); + chat.bottom_pane.set_composer_text("draft A".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.on_task_complete(None); + assert_eq!(chat.bottom_pane.composer_text(), "draft A"); +} + +#[tokio::test] +async fn ctrl_s_does_not_stash_when_slash_popup_is_active() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.insert_str("/"); + assert_eq!(chat.bottom_pane.composer_text(), "/"); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert_eq!(chat.bottom_pane.composer_text(), "/"); + + // Ensure nothing was stashed to restore. + chat.on_task_started(); + chat.on_task_complete(None); + assert_eq!(chat.bottom_pane.composer_text(), "/"); +} + +#[tokio::test] +async fn ctrl_s_restore_is_deferred_until_composer_is_empty() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.on_task_started(); + chat.bottom_pane.set_composer_text("draft A".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + + // User starts writing a different draft before the task ends; do not overwrite it. + chat.bottom_pane.set_composer_text("draft B".to_string()); + chat.on_task_complete(None); + assert_eq!(chat.bottom_pane.composer_text(), "draft B"); + + // Next completion while the composer is empty should restore the stash. + chat.bottom_pane.set_composer_text(String::new()); + chat.on_task_started(); + chat.on_task_complete(None); + assert_eq!(chat.bottom_pane.composer_text(), "draft A"); +} + +#[tokio::test] +async fn ctrl_s_overwrites_existing_stash() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.set_composer_text("draft A".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + chat.bottom_pane.set_composer_text("draft B".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + chat.bottom_pane.set_composer_text("send now".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "draft B"); +} + #[tokio::test] async fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 0073173fdc7..d39cd62dede 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -57,6 +57,7 @@ use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; +use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; @@ -85,6 +86,25 @@ struct AttachedImage { path: PathBuf, } +#[derive(Debug)] +struct StashedDraft { + text: String, + pending_pastes: Vec<(String, String)>, + attached_images: Vec, +} + +impl StashedDraft { + fn preview(&self) -> String { + let first_line = self + .text + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or_default() + .trim(); + truncate_text(first_line, 20) + } +} + enum PromptSelectionMode { Completion, Submit, @@ -107,6 +127,7 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, + stashed_draft: Option, large_paste_counters: HashMap, has_focus: bool, attached_images: Vec, @@ -162,6 +183,7 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), + stashed_draft: None, large_paste_counters: HashMap::new(), has_focus: has_input_focus, attached_images: Vec::new(), @@ -312,6 +334,69 @@ impl ChatComposer { self.sync_popups(); } + /// Replace the composer content with text, rebuilding attachment elements. + pub(crate) fn apply_external_edit(&mut self, text: String) { + self.pending_pastes.clear(); + + let mut placeholder_counts: HashMap = HashMap::new(); + for placeholder in self.attached_images.iter().map(|img| &img.placeholder) { + if placeholder_counts.contains_key(placeholder) { + continue; + } + let count = text.match_indices(placeholder).count(); + if count > 0 { + placeholder_counts.insert(placeholder.clone(), count); + } + } + + let mut kept_images = Vec::new(); + for img in self.attached_images.drain(..) { + if let Some(count) = placeholder_counts.get_mut(&img.placeholder) + && *count > 0 + { + *count -= 1; + kept_images.push(img); + } + } + self.attached_images = kept_images; + + self.textarea.set_text(""); + let mut remaining: HashMap<&str, usize> = HashMap::new(); + for img in &self.attached_images { + *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; + } + + let mut occurrences: Vec<(usize, &str)> = Vec::new(); + for placeholder in remaining.keys() { + for (pos, _) in text.match_indices(placeholder) { + occurrences.push((pos, *placeholder)); + } + } + occurrences.sort_unstable_by_key(|(pos, _)| *pos); + + let mut idx = 0usize; + for (pos, ph) in occurrences { + let Some(count) = remaining.get_mut(ph) else { + continue; + }; + if *count == 0 { + continue; + } + if pos > idx { + self.textarea.insert_str(&text[idx..pos]); + } + self.textarea.insert_element(ph); + *count -= 1; + idx = pos + ph.len(); + } + if idx < text.len() { + self.textarea.insert_str(&text[idx..]); + } + + self.textarea.set_cursor(self.textarea.text().len()); + self.sync_popups(); + } + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { if self.is_empty() { return None; @@ -417,6 +502,41 @@ impl ChatComposer { result } + fn stash_draft(&mut self) -> bool { + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + + if self.is_empty() { + return false; + } + + self.stashed_draft = Some(StashedDraft { + text: self.textarea.text().to_string(), + pending_pastes: std::mem::take(&mut self.pending_pastes), + attached_images: std::mem::take(&mut self.attached_images), + }); + + self.set_text_content(String::new()); + self.active_popup = ActivePopup::None; + true + } + + pub(crate) fn restore_stashed_draft_if_possible(&mut self) -> bool { + if !self.is_empty() { + return false; + } + + let Some(stashed) = self.stashed_draft.take() else { + return false; + }; + + self.attached_images = stashed.attached_images; + self.apply_external_edit(stashed.text); + self.pending_pastes = stashed.pending_pastes; + true + } + /// Return true if either the slash-command popup or the file-search popup is active. pub(crate) fn popup_active(&self) -> bool { !matches!(self.active_popup, ActivePopup::None) @@ -1042,6 +1162,12 @@ impl ChatComposer { self.footer_mode = reset_mode_after_activity(self.footer_mode); } match key_event { + KeyEvent { + code: KeyCode::Char('s'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => (InputResult::None, self.stash_draft()), KeyEvent { code: KeyCode::Char('d'), modifiers: crossterm::event::KeyModifiers::CONTROL, @@ -1537,6 +1663,7 @@ impl ChatComposer { } fn footer_props(&self) -> FooterProps { + let stashed_draft_preview = self.stashed_draft.as_ref().map(StashedDraft::preview); FooterProps { mode: self.footer_mode(), esc_backtrack_hint: self.esc_backtrack_hint, @@ -1549,6 +1676,7 @@ impl ChatComposer { transcript_scroll_position: self.transcript_scroll_position, transcript_copy_selection_key: self.transcript_copy_selection_key, transcript_copy_feedback: self.transcript_copy_feedback, + stashed_draft_preview, } } @@ -1871,7 +1999,7 @@ impl Renderable for ChatComposer { let footer_props = self.footer_props(); let custom_height = self.custom_footer_height(); let footer_hint_height = - custom_height.unwrap_or_else(|| footer_height(footer_props)); + custom_height.unwrap_or_else(|| footer_height(footer_props.clone())); let footer_spacing = Self::footer_spacing(footer_hint_height); let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { let [_, hint_rect] = Layout::vertical([ diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index f4ead67be61..0b7445b95d4 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -15,7 +15,7 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, @@ -28,6 +28,7 @@ pub(crate) struct FooterProps { pub(crate) transcript_scroll_position: Option<(usize, usize)>, pub(crate) transcript_copy_selection_key: KeyBinding, pub(crate) transcript_copy_feedback: Option, + pub(crate) stashed_draft_preview: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -111,10 +112,18 @@ fn footer_lines(props: FooterProps) -> Vec> { props.context_window_used_tokens, ); line.push_span(" · ".dim()); - line.extend(vec![ - key_hint::plain(KeyCode::Char('?')).into(), - " for shortcuts".dim(), - ]); + if let Some(preview) = props.stashed_draft_preview { + line.extend(vec![ + "STASHED:".cyan(), + " ".into(), + Span::from(format!("\"{preview}\"")).dim(), + ]); + } else { + line.extend(vec![ + key_hint::plain(KeyCode::Char('?')).into(), + " for shortcuts".dim(), + ]); + } if props.transcript_scrolled { line.push_span(" · ".dim()); line.push_span(key_hint::plain(KeyCode::PageUp)); @@ -152,10 +161,21 @@ fn footer_lines(props: FooterProps) -> Vec> { shortcut_overlay_lines(state) } FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => vec![context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - )], + FooterMode::ContextOnly => { + let mut line = context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + ); + if let Some(preview) = props.stashed_draft_preview { + line.push_span(" · ".dim()); + line.extend(vec![ + "STASHED:".cyan(), + " ".into(), + Span::from(format!("\"{preview}\"")).dim(), + ]); + } + vec![line] + } }; apply_copy_feedback(&mut lines, props.transcript_copy_feedback); lines @@ -206,6 +226,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut newline = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); + let mut stash_draft = Line::from(""); let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); @@ -217,6 +238,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::InsertNewline => newline = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, + ShortcutId::StashDraft => stash_draft = text, ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, @@ -229,9 +251,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { newline, file_paths, paste_image, + stash_draft, edit_previous, quit, - Line::from(""), show_transcript, ]; @@ -305,6 +327,7 @@ enum ShortcutId { InsertNewline, FilePaths, PasteImage, + StashDraft, EditPrevious, Quit, ShowTranscript, @@ -425,6 +448,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to paste images", }, + ShortcutDescriptor { + id: ShortcutId::StashDraft, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('s')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to stash prompt", + }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { @@ -462,7 +494,7 @@ mod tests { use ratatui::backend::TestBackend; fn snapshot_footer(name: &str, props: FooterProps) { - let height = footer_height(props).max(1); + let height = footer_height(props.clone()).max(1); let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); terminal .draw(|f| { @@ -489,6 +521,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -506,6 +539,7 @@ mod tests { transcript_scroll_position: Some((3, 42)), transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -523,6 +557,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -540,6 +575,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -557,6 +593,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -574,6 +611,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -591,6 +629,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -608,6 +647,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -625,6 +665,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: None, + stashed_draft_preview: None, }, ); @@ -642,6 +683,7 @@ mod tests { transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), transcript_copy_feedback: Some(TranscriptCopyFeedback::Copied), + stashed_draft_preview: None, }, ); } diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 2ebd0715e7d..c2546128a91 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -266,6 +266,14 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn restore_stashed_draft_if_possible(&mut self) -> bool { + let restored = self.composer.restore_stashed_draft_if_possible(); + if restored { + self.request_redraw(); + } + restored + } + /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 178182bfd77..4680ece035f 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -1,5 +1,6 @@ --- source: tui2/src/bottom_pane/chat_composer.rs +assertion_line: 2204 expression: terminal.backend() --- " " @@ -10,7 +11,7 @@ expression: terminal.backend() " " " " " " -" / for commands shift + enter for newline " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" ctrl + s to stash prompt esc again to edit previous message " +" ctrl + c to exit ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 47508f32406..e5a9b26fdbe 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -1,8 +1,9 @@ --- source: tui2/src/bottom_pane/footer.rs +assertion_line: 505 expression: terminal.backend() --- -" / for commands shift + enter for newline " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" ctrl + s to stash prompt esc again to edit previous message " +" ctrl + c to exit ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index b661894a18b..476417fbf89 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -542,6 +542,7 @@ impl ChatWidget { self.running_commands.clear(); self.suppressed_exec_calls.clear(); self.last_unified_wait = None; + self.bottom_pane.restore_stashed_draft_if_possible(); self.request_redraw(); // If there is a queued user message, send exactly one now to begin the next turn. @@ -674,6 +675,7 @@ impl ChatWidget { self.running_commands.clear(); self.suppressed_exec_calls.clear(); self.last_unified_wait = None; + self.bottom_pane.restore_stashed_draft_if_possible(); self.stream_controller = None; self.maybe_show_pending_rate_limit_prompt(); } @@ -1490,12 +1492,16 @@ impl ChatWidget { _ => { match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted(text) => { + let was_running = self.bottom_pane.is_task_running(); // If a task is running, queue the user input to be sent after the turn completes. let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), }; self.queue_user_message(user_message); + if !was_running { + self.bottom_pane.restore_stashed_draft_if_possible(); + } } InputResult::Command(cmd) => { self.dispatch_command(cmd); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 8b216812dfd..0fb8f9d8658 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -1080,6 +1080,95 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { ); } +#[tokio::test] +async fn ctrl_s_noops_when_composer_empty() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.on_task_started(); + chat.on_task_complete(None); + assert!(chat.bottom_pane.composer_text().is_empty()); +} + +#[tokio::test] +async fn ctrl_s_stashes_and_restores_after_next_submission_when_idle() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.set_composer_text("draft A".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.bottom_pane.set_composer_text("send now".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "draft A"); +} + +#[tokio::test] +async fn ctrl_s_stashes_during_task_and_restores_after_task_complete() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.on_task_started(); + chat.bottom_pane.set_composer_text("draft A".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.on_task_complete(None); + assert_eq!(chat.bottom_pane.composer_text(), "draft A"); +} + +#[tokio::test] +async fn ctrl_s_does_not_stash_when_slash_popup_is_active() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.insert_str("/"); + assert_eq!(chat.bottom_pane.composer_text(), "/"); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert_eq!(chat.bottom_pane.composer_text(), "/"); + + chat.on_task_started(); + chat.on_task_complete(None); + assert_eq!(chat.bottom_pane.composer_text(), "/"); +} + +#[tokio::test] +async fn ctrl_s_restore_is_deferred_until_composer_is_empty() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.on_task_started(); + chat.bottom_pane.set_composer_text("draft A".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.bottom_pane.composer_text().is_empty()); + + chat.bottom_pane.set_composer_text("draft B".to_string()); + chat.on_task_complete(None); + assert_eq!(chat.bottom_pane.composer_text(), "draft B"); + + chat.bottom_pane.set_composer_text(String::new()); + chat.on_task_started(); + chat.on_task_complete(None); + assert_eq!(chat.bottom_pane.composer_text(), "draft A"); +} + +#[tokio::test] +async fn ctrl_s_overwrites_existing_stash() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.set_composer_text("draft A".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + chat.bottom_pane.set_composer_text("draft B".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + chat.bottom_pane.set_composer_text("send now".to_string()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "draft B"); +} + #[tokio::test] async fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;