diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 979213112f8..4aeae8da2d4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -121,6 +121,7 @@ use super::footer::render_footer_hint_items; use super::footer::render_footer_line; use super::footer::reset_mode_after_activity; use super::footer::single_line_footer_layout; +use super::footer::stash_hint_mode; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; @@ -183,6 +184,7 @@ pub enum InputResult { text: String, text_elements: Vec, }, + Stashed(PreparedDraft), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -247,6 +249,32 @@ impl ChatComposerConfig { } } } + +#[derive(Clone, Copy, Debug, PartialEq)] +enum PrepareMode { + ForSubmit, + ForStash, +} + +#[derive(Clone, Debug, PartialEq)] +/// Snapshot of composer state prepared for stashing or submission. +/// +/// This captures the rendered text plus the associated text elements, local +/// image attachments, and any pending paste payloads so the state can be +/// restored later. +pub(crate) struct PreparedDraft { + /// Composer text. For submission this is after preprocessing (e.g., prompt + /// expansion); for stash this is the raw user input. + pub(crate) text: String, + /// Text elements corresponding to placeholders within `text`. + pub(crate) text_elements: Vec, + /// Local image attachments paired with their placeholder labels. + pub(crate) local_images: Vec, + /// Pending paste payloads keyed by placeholder inserted into `text`. + pub(crate) pending_pastes: Vec<(String, String)>, + /// Mapping from mention names to their resolved paths. + pub(crate) mention_paths: HashMap, +} pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -478,6 +506,13 @@ impl ChatComposer { self.textarea.is_empty() } + // Returns true if the composer has no user input, no attached images, and no pending pastes. + pub(crate) fn has_draft(&self) -> bool { + !self.textarea.is_empty() + || !self.attached_images.is_empty() + || !self.pending_pastes.is_empty() + } + /// Record the history metadata advertised by `SessionConfiguredEvent` so /// that the composer can navigate cross-session history. pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { @@ -749,6 +784,45 @@ impl ChatComposer { self.textarea.set_cursor(self.textarea.text().len()); } + /// Replaces the entire composer with `text`, resets cursor, sets pending pastes, + /// preserves placeholder-to-path mappings for local images, and restores mention paths. + pub(crate) fn set_text_content_with_local_images_and_pending_pastes( + &mut self, + text: String, + text_elements: Vec, + local_images: Vec, + pending_pastes: Vec<(String, String)>, + mention_paths: HashMap, + ) { + // Clear any existing content, placeholders, and attachments first. + self.textarea.set_text_clearing_elements(""); + self.pending_pastes.clear(); + self.attached_images.clear(); + self.mention_paths.clear(); + + self.textarea.set_text_with_elements(&text, &text_elements); + + let image_placeholders: HashSet = text_elements + .iter() + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) + .collect(); + self.attached_images = local_images + .into_iter() + .filter(|img| image_placeholders.contains(&img.placeholder)) + .map(|img| AttachedImage { + placeholder: img.placeholder, + path: img.path, + }) + .collect(); + + self.pending_pastes = pending_pastes; + self.pending_pastes + .retain(|(ph, _)| self.textarea.text().contains(ph)); + self.mention_paths = mention_paths; + self.textarea.set_cursor(0); + self.sync_popups(); + } + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { if self.is_empty() { return None; @@ -1796,9 +1870,7 @@ impl ChatComposer { } } - /// Prepare text for submission/queuing. Returns None if submission should be suppressed. - /// On success, clears pending paste payloads because placeholders have been expanded. - fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { + fn prepare_composer_text(&mut self, mode: PrepareMode) -> Option { let mut text = self.textarea.text().to_string(); let original_input = text.clone(); let original_text_elements = self.textarea.text_elements(); @@ -1812,7 +1884,7 @@ impl ChatComposer { let input_starts_with_space = original_input.starts_with(' '); self.textarea.set_text_clearing_elements(""); - if !self.pending_pastes.is_empty() { + if matches!(mode, PrepareMode::ForSubmit) && !self.pending_pastes.is_empty() { // Expand placeholders so element byte ranges stay aligned. let (expanded, expanded_elements) = Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); @@ -1823,51 +1895,54 @@ impl ChatComposer { let expanded_input = text.clone(); // If there is neither text nor attachments, suppress submission entirely. - text = text.trim().to_string(); - text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); + if matches!(mode, PrepareMode::ForSubmit) { + text = text.trim().to_string(); + text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - if self.slash_commands_enabled() - && let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) - { - let treat_as_plain_text = input_starts_with_space || name.contains('/'); - if !treat_as_plain_text { - let is_builtin = slash_commands::find_builtin_command( - name, - self.collaboration_modes_enabled, - self.connectors_enabled, - self.personality_command_enabled, - self.windows_degraded_sandbox_active, - ) - .is_some(); - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - let is_known_prompt = name - .strip_prefix(&prompt_prefix) - .map(|prompt_name| { - self.custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name) - }) - .unwrap_or(false); - if !is_builtin && !is_known_prompt { - let message = format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, None), - ))); - self.set_text_content( - original_input.clone(), - original_text_elements, - original_local_image_paths, - ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); - return None; + if self.slash_commands_enabled() + && let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) + { + let treat_as_plain_text = input_starts_with_space || name.contains('/'); + if !treat_as_plain_text { + let is_builtin = slash_commands::find_builtin_command( + name, + self.collaboration_modes_enabled, + self.connectors_enabled, + self.personality_command_enabled, + self.windows_degraded_sandbox_active, + ) + .is_some(); + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = name + .strip_prefix(&prompt_prefix) + .map(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) + .unwrap_or(false); + if !is_builtin && !is_known_prompt { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } } } } - if self.slash_commands_enabled() { + // Prompt expansion only applies to submission; stash preserves raw input. + if matches!(mode, PrepareMode::ForSubmit) && self.slash_commands_enabled() { let expanded_prompt = match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { Ok(expanded) => expanded, @@ -1890,13 +1965,17 @@ impl ChatComposer { text_elements = expanded.text_elements; } } - // Custom prompt expansion can remove or rewrite image placeholders, so prune any - // attachments that no longer have a corresponding placeholder in the expanded text. - self.prune_attached_images_for_submission(&text, &text_elements); + // Image pruning applies to all submissions (prompt expansion can rewrite placeholders, + // and users may delete placeholder text manually). Stash preserves raw attachments. + if matches!(mode, PrepareMode::ForSubmit) { + self.prune_attached_images_for_submission(&text, &text_elements); + } if text.is_empty() && self.attached_images.is_empty() { return None; } - if !text.is_empty() || !self.attached_images.is_empty() { + if matches!(mode, PrepareMode::ForSubmit) + && (!text.is_empty() || !self.attached_images.is_empty()) + { let local_image_paths = self .attached_images .iter() @@ -1908,8 +1987,26 @@ impl ChatComposer { local_image_paths, }); } + let pending_pastes = self.pending_pastes.clone(); self.pending_pastes.clear(); - Some((text, text_elements)) + + let local_images = self.local_images(); + let mention_paths = self.mention_paths.clone(); + + Some(PreparedDraft { + text, + text_elements, + local_images, + pending_pastes, + mention_paths, + }) + } + + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. + /// On success, clears pending paste payloads because placeholders have been expanded. + fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { + self.prepare_composer_text(PrepareMode::ForSubmit) + .map(|prepared| (prepared.text, prepared.text_elements)) } /// Common logic for handling message submission/queuing. @@ -2009,6 +2106,19 @@ impl ChatComposer { } } + fn handle_stash(&mut self) -> (InputResult, bool) { + let Some(prepared_draft) = self.prepare_composer_text(PrepareMode::ForStash) else { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event("Nothing to stash".to_string()), + ))); + return (InputResult::None, true); + }; + + self.set_text_content(String::new(), Vec::new(), Vec::new()); + + (InputResult::Stashed(prepared_draft), true) + } + /// Check if the first line is a bare slash command (no args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { @@ -2136,6 +2246,11 @@ impl ChatComposer { let should_queue = !self.steer_enabled; self.handle_submission(should_queue) } + KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.handle_stash(), input => self.handle_input_basic(input), } } @@ -2417,6 +2532,7 @@ impl ChatComposer { match self.footer_mode { FooterMode::EscHint => FooterMode::EscHint, + FooterMode::CantUnstashHint => FooterMode::CantUnstashHint, FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => { FooterMode::QuitShortcutReminder @@ -2798,6 +2914,14 @@ impl ChatComposer { self.footer_mode = reset_mode_after_activity(self.footer_mode); } } + + pub(crate) fn set_stash_hint(&mut self, show: bool) { + if show { + self.footer_mode = stash_hint_mode(self.footer_mode, self.is_task_running); + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + } } fn skill_display_name(skill: &SkillMetadata) -> &str { @@ -2880,6 +3004,7 @@ impl ChatComposer { FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint + | FooterMode::CantUnstashHint | FooterMode::ComposerHasDraft => false, }; let show_queue_hint = match footer_props.mode { @@ -2889,6 +3014,7 @@ impl ChatComposer { FooterMode::QuitShortcutReminder | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay + | FooterMode::CantUnstashHint | FooterMode::EscHint => false, }; let context_line = context_window_line( @@ -2950,6 +3076,7 @@ impl ChatComposer { } FooterMode::EscHint | FooterMode::QuitShortcutReminder + | FooterMode::CantUnstashHint | FooterMode::ShortcutOverlay => None, } }; @@ -2957,6 +3084,7 @@ impl ChatComposer { footer_props.mode, FooterMode::EscHint | FooterMode::QuitShortcutReminder + | FooterMode::CantUnstashHint | FooterMode::ShortcutOverlay ) { false @@ -4591,6 +4719,9 @@ mod tests { InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } + InputResult::Stashed(_) => { + panic!("expected command dispatch, but composer stashed literal text") + } InputResult::None => panic!("expected Command result for '/init'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -4692,6 +4823,9 @@ mod tests { InputResult::Queued { .. } => { panic!("expected command dispatch after Tab completion, got literal queue") } + InputResult::Stashed(_) => { + panic!("expected command dispatch, but composer stashed literal text") + } InputResult::None => panic!("expected Command result for '/diff'"), } assert!(composer.textarea.is_empty()); @@ -4731,6 +4865,9 @@ mod tests { InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } + InputResult::Stashed(_) => { + panic!("expected command dispatch, but composer stashed literal text") + } InputResult::None => panic!("expected Command result for '/mention'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -6936,4 +7073,158 @@ mod tests { }; assert_eq!(composer.cursor_pos(area), None); } + + #[test] + fn ctrl_s_stashes_message() { + 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, + ); + + composer.set_text_content("This will be stashed".to_string(), Vec::new(), Vec::new()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + let InputResult::Stashed(stash) = result else { + panic!("expected stashed result, got {result:?}"); + }; + + assert_eq!(stash.text, "This will be stashed".to_string()); + assert!(stash.text_elements.is_empty()); + assert!(stash.local_images.is_empty()); + assert!(stash.pending_pastes.is_empty()); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn ctrl_s_stashes_message_with_pending_paste() { + 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, + ); + + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + let placeholder = composer + .pending_pastes + .first() + .expect("expected pending paste") + .0 + .clone(); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + let InputResult::Stashed(stash) = result else { + panic!("expected stashed result, got {result:?}"); + }; + + assert_eq!(stash.text, placeholder); + assert_eq!( + stash.pending_pastes, + vec![(stash.text.clone(), large_content)] + ); + assert!(composer.textarea.is_empty()); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn ctrl_s_stash_preserves_mention_paths() { + // Issue: mention_paths (file/skill mentions inserted via popup) should survive + // stash/restore cycle so they resolve correctly on submission. + 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, + ); + + // Simulate inserting a file mention via popup (which records the path mapping). + let text_with_mention = "Check $myfile for issues".to_string(); + composer.set_text_content(text_with_mention.clone(), Vec::new(), Vec::new()); + composer + .mention_paths + .insert("myfile".to_string(), "/path/to/myfile.rs".to_string()); + + // Stash the draft. + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + let InputResult::Stashed(stash) = result else { + panic!("expected stashed result, got {result:?}"); + }; + + // Verify mention_paths is preserved in the stash. + assert_eq!(stash.text, text_with_mention); + assert_eq!( + stash.mention_paths.get("myfile"), + Some(&"/path/to/myfile.rs".to_string()) + ); + + // Restore the stash and verify mention_paths is restored. + composer.set_text_content_with_local_images_and_pending_pastes( + stash.text, + stash.text_elements, + stash.local_images, + stash.pending_pastes, + stash.mention_paths, + ); + + assert_eq!(composer.current_text(), text_with_mention); + let restored_paths = composer.take_mention_paths(); + assert_eq!( + restored_paths.get("myfile"), + Some(&"/path/to/myfile.rs".to_string()) + ); + } + + #[test] + fn has_draft_detects_text_images_and_pending_pastes() { + 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, + ); + + // 1) empty + assert!(!composer.has_draft()); + + // 2) text + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); + assert!(composer.has_draft()); + + // reset + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + assert!(!composer.has_draft()); + + // 3) pending pastes + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + assert!(composer.has_draft()); + + // reset + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + assert!(!composer.has_draft()); + + // 4) image attachment + composer.attach_image(PathBuf::from("/tmp/fake.png")); + assert!(composer.has_draft()); + } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index c297b9e3626..21546499d8a 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -125,6 +125,7 @@ pub(crate) enum FooterMode { /// The shortcuts hint is suppressed here; when a task is running with /// steer enabled, this mode can show the queue hint instead. ComposerHasDraft, + CantUnstashHint, } pub(crate) fn toggle_shortcut_mode( @@ -156,9 +157,18 @@ pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> Foote } } +pub(crate) fn stash_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { + if is_task_running { + current + } else { + FooterMode::CantUnstashHint + } +} + pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { match current { - FooterMode::EscHint + FooterMode::CantUnstashHint + | FooterMode::EscHint | FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder | FooterMode::ComposerHasDraft => FooterMode::ComposerEmpty, @@ -172,6 +182,7 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 { FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint + | FooterMode::CantUnstashHint | FooterMode::ComposerHasDraft => false, }; let show_queue_hint = match props.mode { @@ -179,6 +190,7 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 { FooterMode::QuitShortcutReminder | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay + | FooterMode::CantUnstashHint | FooterMode::EscHint => false, }; footer_from_props_lines(props, None, false, show_shortcuts_hint, show_queue_hint).len() as u16 @@ -563,6 +575,7 @@ fn footer_from_props_lines( shortcut_overlay_lines(state) } FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], + FooterMode::CantUnstashHint => vec![show_stash_preserved_hint_line()], FooterMode::ComposerHasDraft => { let state = LeftSideState { hint: if show_queue_hint { @@ -643,6 +656,12 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { } } +fn show_stash_preserved_hint_line() -> Line<'static> { + Line::from(vec![ + "Stash preserved — submit current message to auto-restore, or clear input and press Ctrl+S to restore".dim(), + ]) +} + fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut commands = Line::from(""); let mut shell_commands = Line::from(""); @@ -986,6 +1005,7 @@ mod tests { FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint + | FooterMode::CantUnstashHint | FooterMode::ComposerHasDraft => false, }; let show_queue_hint = match props.mode { @@ -993,6 +1013,7 @@ mod tests { FooterMode::QuitShortcutReminder | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay + | FooterMode::CantUnstashHint | FooterMode::EscHint => false, }; let left_width = footer_line_width( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a632fd4468a..ae6ed81ed59 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -19,6 +19,7 @@ use std::path::PathBuf; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::queued_user_messages::QueuedUserMessages; +use crate::bottom_pane::stash_indicator::StashIndicator; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; use crate::key_hint; use crate::key_hint::KeyBinding; @@ -78,8 +79,10 @@ pub mod popup_consts; mod queued_user_messages; mod scroll_state; mod selection_popup_common; +mod stash_indicator; mod textarea; mod unified_exec_footer; +pub(crate) use chat_composer::PreparedDraft; pub(crate) use feedback_view::FeedbackNoteView; /// How long the "press again to quit" hint stays visible. @@ -149,6 +152,8 @@ pub(crate) struct BottomPane { unified_exec_footer: UnifiedExecFooter, /// Queued user messages to show above the composer while a turn is running. queued_user_messages: QueuedUserMessages, + /// Shows an indicator when there are stashed changes. + stash_indicator: StashIndicator, context_window_percent: Option, context_window_used_tokens: Option, } @@ -197,6 +202,7 @@ impl BottomPane { status: None, unified_exec_footer: UnifiedExecFooter::new(), queued_user_messages: QueuedUserMessages::new(), + stash_indicator: StashIndicator::new(), esc_backtrack_hint: false, animations_enabled, context_window_percent: None, @@ -407,6 +413,19 @@ impl BottomPane { self.request_redraw(); } + /// Restores composer text, images, pending pastes, and mention paths from a PreparedDraft + pub(crate) fn restore_stash(&mut self, stash: PreparedDraft) { + self.composer + .set_text_content_with_local_images_and_pending_pastes( + stash.text, + stash.text_elements, + stash.local_images, + stash.pending_pastes, + stash.mention_paths, + ); + self.request_redraw(); + } + #[allow(dead_code)] pub(crate) fn set_composer_input_enabled( &mut self, @@ -525,6 +544,11 @@ impl BottomPane { } } + pub(crate) fn show_stash_hint(&mut self) { + self.composer.set_stash_hint(true); + self.request_redraw(); + } + // esc_backtrack_hint_visible removed; hints are controlled internally. pub fn set_task_running(&mut self, running: bool) { @@ -602,6 +626,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_stashed(&mut self, stashed: bool) { + self.stash_indicator.stash_exists = stashed; + self.request_redraw(); + } + pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec) { if self.unified_exec_footer.set_processes(processes) { self.request_redraw(); @@ -618,6 +647,11 @@ impl BottomPane { self.composer.is_empty() } + // Check if the composer has a text, images, or pending pastes draft. + pub(crate) fn composer_has_draft(&self) -> bool { + self.composer.has_draft() + } + pub(crate) fn is_task_running(&self) -> bool { self.is_task_running } @@ -799,13 +833,21 @@ impl BottomPane { flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer)); } let has_queued_messages = !self.queued_user_messages.messages.is_empty(); + let has_stash = self.stash_indicator.stash_exists; + let has_aux = has_queued_messages || has_stash; + let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); - if has_queued_messages && has_status_or_footer { + if has_aux && has_status_or_footer { flex.push(0, RenderableItem::Owned("".into())); } - flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if !has_queued_messages && has_status_or_footer { + if has_queued_messages { + flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); + } + if has_stash { + flex.push(0, RenderableItem::Borrowed(&self.stash_indicator)); + } + if !has_aux && has_status_or_footer { flex.push(0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); @@ -814,6 +856,11 @@ impl BottomPane { RenderableItem::Owned(Box::new(flex2)) } } + + #[cfg(test)] + pub(crate) fn stash_exists(&self) -> bool { + self.stash_indicator.stash_exists + } } impl Renderable for BottomPane { @@ -833,9 +880,11 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_core::protocol::Op; + use codex_protocol::models::local_image_label_text; use codex_protocol::protocol::SkillScope; use crossterm::event::KeyModifiers; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use std::cell::Cell; @@ -1162,6 +1211,122 @@ mod tests { ); } + #[test] + fn status_and_stash_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.set_stashed(true); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!("status_and_stash_snapshot", render_snapshot(&pane, area)); + } + + #[test] + fn status_and_queued_messages_and_stash_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.set_stashed(true); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_and_queued_messages_and_stash_snapshot", + render_snapshot(&pane, area) + ); + } + + #[test] + fn restore_stash_preserves_image_placeholders() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + let placeholder1 = local_image_label_text(1); + let placeholder3 = local_image_label_text(3); + let text = format!("{placeholder1} then {placeholder3}"); + let mut text_elements = Vec::new(); + for placeholder in [&placeholder1, &placeholder3] { + let start = text + .find(placeholder) + .expect("placeholder should exist in text"); + let end = start + placeholder.len(); + text_elements.push(TextElement::new((start..end).into(), None)); + } + + let path1 = PathBuf::from("/tmp/image1.png"); + let path3 = PathBuf::from("/tmp/image3.png"); + let stash = PreparedDraft { + text: text.clone(), + text_elements, + local_images: vec![ + LocalImageAttachment { + placeholder: placeholder1.clone(), + path: path1.clone(), + }, + LocalImageAttachment { + placeholder: placeholder3.clone(), + path: path3.clone(), + }, + ], + pending_pastes: Vec::new(), + mention_paths: HashMap::new(), + }; + + pane.restore_stash(stash); + + assert_eq!(pane.composer.current_text(), text); + assert_eq!( + pane.composer.local_images(), + vec![ + LocalImageAttachment { + placeholder: placeholder1, + path: path1, + }, + LocalImageAttachment { + placeholder: placeholder3, + path: path3, + }, + ] + ); + } + #[test] fn esc_with_skill_popup_does_not_interrupt_task() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__stash_indicator__tests__render_stash_wrapped_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__stash_indicator__tests__render_stash_wrapped_message.snap new file mode 100644 index 00000000000..578c98573e4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__stash_indicator__tests__render_stash_wrapped_message.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/bottom_pane/stash_indicator.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 20, height: 4 }, + content: [ + " ↳ Stashed ", + " (restores after ", + " current message ", + " is sent) ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 19, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 19, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 12, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_and_stash_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_and_stash_snapshot.snap new file mode 100644 index 00000000000..f4538f125d3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_and_stash_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + + ↳ Queued follow-up question + ⌥ + ↑ edit + ↳ Stashed (restores after current message is + sent) + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_stash_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_stash_snapshot.snap new file mode 100644 index 00000000000..ac5e9b74fe5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_stash_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + + ↳ Stashed (restores after current message is + sent) + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/stash_indicator.rs b/codex-rs/tui/src/bottom_pane/stash_indicator.rs new file mode 100644 index 00000000000..d1019454578 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/stash_indicator.rs @@ -0,0 +1,83 @@ +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::render::renderable::Renderable; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +pub(crate) struct StashIndicator { + pub stash_exists: bool, +} + +impl StashIndicator { + pub(crate) fn new() -> Self { + Self { + stash_exists: false, + } + } + + fn as_renderable(&self, width: u16) -> Box { + if !self.stash_exists || width < 4 { + return Box::new(()); + } + + let wrapped = word_wrap_lines( + vec!["Stashed (restores after current message is sent)"] + .into_iter() + .map(|line| line.dim().italic()), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ↳ ".dim())) + .subsequent_indent(Line::from(" ")), + ); + + Paragraph::new(wrapped).into() + } +} + +impl Renderable for StashIndicator { + fn render(&self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) { + if area.is_empty() { + return; + } + + self.as_renderable(area.width).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable(width).desired_height(width) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + #[test] + fn desired_height_no_stash() { + let stash = StashIndicator::new(); + assert_eq!(stash.desired_height(40), 0); + } + + #[test] + fn desired_height_stash() { + let mut stash = StashIndicator::new(); + stash.stash_exists = true; + assert_eq!(stash.desired_height(40), 2); + } + + #[test] + fn render_wrapped_message() { + let mut stash = StashIndicator::new(); + stash.stash_exists = true; + let width = 20; + let height = stash.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + stash.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_stash_wrapped_message", format!("{buf:?}")); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0c8c7cf335..b2bd79f87b5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -29,6 +29,7 @@ use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use crate::bottom_pane::PreparedDraft; use crate::version::CODEX_CLI_VERSION; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; @@ -575,6 +576,8 @@ pub(crate) struct ChatWidget { // Current session rollout path (if known) current_rollout_path: Option, external_editor_state: ExternalEditorState, + // Stashed message, if any + stash: Option, } /// Snapshot of active-cell state that affects transcript overlay rendering. @@ -2278,9 +2281,11 @@ impl ChatWidget { feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, + stash: None, }; widget.prefetch_rate_limits(); + widget.refresh_stash_indicator(); widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); @@ -2423,9 +2428,11 @@ impl ChatWidget { feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, + stash: None, }; widget.prefetch_rate_limits(); + widget.refresh_stash_indicator(); widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); @@ -2557,9 +2564,11 @@ impl ChatWidget { feedback_audience, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, + stash: None, }; widget.prefetch_rate_limits(); + widget.refresh_stash_indicator(); widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); @@ -2672,6 +2681,13 @@ impl ChatWidget { self.request_redraw(); } } + KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::CONTROL, + .. + } if self.bottom_pane.no_modal_or_popup_active() && self.stash.is_some() => { + self.restore_stash(); + } _ => match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted { text, @@ -2695,6 +2711,10 @@ impl ChatWidget { } else { self.queue_user_message(user_message); } + + // Only attempts to restore stash if the composer is still empty and has no + // attachments/pending pastes + self.restore_stash(); } InputResult::Queued { text, @@ -2716,13 +2736,32 @@ impl ChatWidget { InputResult::CommandWithArgs(cmd, args) => { self.dispatch_command_with_args(cmd, args); } + InputResult::Stashed(stash) => { + self.stash = Some(stash); + self.refresh_stash_indicator(); + self.request_redraw(); + } InputResult::None => {} }, } } + pub(crate) fn restore_stash(&mut self) -> bool { + if self.bottom_pane.composer_has_draft() { + self.show_stash_hint(); + return false; + } + + if let Some(stash) = self.stash.take() { + self.bottom_pane.restore_stash(stash); + self.refresh_stash_indicator(); + return true; + } + + false + } + pub(crate) fn attach_image(&mut self, path: PathBuf) { - tracing::info!("attach_image path={path:?}"); self.bottom_pane.attach_image(path); self.request_redraw(); } @@ -3605,6 +3644,11 @@ impl ChatWidget { self.bottom_pane.set_queued_user_messages(messages); } + /// Update the stash indicator in the bottom pane. + fn refresh_stash_indicator(&mut self) { + self.bottom_pane.set_stashed(self.stash.is_some()); + } + pub(crate) fn add_diff_in_progress(&mut self) { self.request_redraw(); } @@ -5756,6 +5800,11 @@ impl ChatWidget { pub(crate) fn clear_esc_backtrack_hint(&mut self) { self.bottom_pane.clear_esc_backtrack_hint(); } + + pub(crate) fn show_stash_hint(&mut self) { + self.bottom_pane.show_stash_hint(); + } + /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&mut self, op: Op) { // Record outbound operation for session replay fidelity. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d24504578c6..089f0bbea96 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -850,6 +850,7 @@ async fn make_chatwidget_manual( feedback_audience: FeedbackAudience::External, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, + stash: None, }; widget.set_model(&resolved_model); (widget, rx, op_rx) @@ -5007,3 +5008,44 @@ async fn review_queues_user_messages_snapshot() { .unwrap(); assert_snapshot!(term.backend().vt100().screen().contents()); } + +#[tokio::test] +async fn stash_indicator() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane + .set_composer_text("I will be stashed".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + assert!(chat.stash.is_some()); + assert!(chat.bottom_pane.stash_exists()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(chat.stash.is_none()); + assert!(!chat.bottom_pane.stash_exists()); + assert_eq!( + chat.bottom_pane.composer_text(), + "I will be stashed".to_string() + ); +} + +#[tokio::test] +async fn auto_restore_stash_after_submit() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + + let stashed_text = "stashed draft".to_string(); + chat.bottom_pane + .set_composer_text(stashed_text.clone(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + + assert!(chat.stash.is_some()); + + chat.bottom_pane + .set_composer_text("steering message".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.stash.is_none()); + assert_eq!(chat.bottom_pane.composer_text(), stashed_text); +}