From cec6969d6dcbce7f54ac39db864438b695f06b2d Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 02:01:27 -0300 Subject: [PATCH 01/21] feat: stashes current message with Ctrl+S The message keeps stashed until the user submits the current message. Right after sending the stashed message is restored. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 163 +++++++++++++----- codex-rs/tui/src/bottom_pane/mod.rs | 42 ++++- ...__tests__render_stash_wrapped_message.snap | 13 ++ .../tui/src/bottom_pane/stash_indicator.rs | 72 ++++++++ codex-rs/tui/src/chatwidget.rs | 33 ++++ codex-rs/tui/src/chatwidget/tests.rs | 1 + 6 files changed, 276 insertions(+), 48 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__stash_indicator__tests__render_stash_wrapped_message.snap create mode 100644 codex-rs/tui/src/bottom_pane/stash_indicator.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 979213112f8..9d511e4c7fa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -183,6 +183,7 @@ pub enum InputResult { text: String, text_elements: Vec, }, + Stashed(PreparedDraft), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -247,6 +248,20 @@ impl ChatComposerConfig { } } } + +#[derive(Clone, Copy, Debug, PartialEq)] +enum PrepareMode { + ForSubmit, + ForStash, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PreparedDraft { + pub(crate) text: String, + pub(crate) text_elements: Vec, + pub(crate) local_images: Vec, + pub(crate) pending_pastes: Vec<(String, String)>, +} pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -739,7 +754,7 @@ impl ChatComposer { self.sync_popups(); } - /// Update the placeholder text without changing input enablement. +/// Update the placeholder text without changing input enablement. pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { self.placeholder_text = placeholder; } @@ -749,6 +764,21 @@ impl ChatComposer { self.textarea.set_cursor(self.textarea.text().len()); } + /// Replaces the entire composer with `text`, resets cursor and sets pending pastes. + pub(crate) fn set_text_content_with_pending_pastes( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + pending_pastes: Vec<(String, String)>, + ) { + self.set_text_content(text, text_elements, local_image_paths); + self.pending_pastes = pending_pastes; + // drops any pending pastes that no longer have placeholders in text + self.pending_pastes + .retain(|(ph, _)| self.textarea.text().contains(ph)); + } + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { if self.is_empty() { return None; @@ -1773,7 +1803,7 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } - fn record_mention_path(&mut self, insert_text: &str, path: &str) { +fn record_mention_path(&mut self, insert_text: &str, path: &str) { let Some(name) = Self::mention_name_from_insert_text(insert_text) else { return; }; @@ -1796,9 +1826,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(); @@ -1823,46 +1851,48 @@ 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; + } } } } @@ -1908,8 +1938,24 @@ 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(); + + Some(PreparedDraft { + text, + text_elements, + local_images, + pending_pastes, + }) + } + + /// 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 +2055,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 +2195,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), } } @@ -4591,6 +4655,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 +4759,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 +4801,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"); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a632fd4468a..b4a5fdf09c0 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,23 @@ impl BottomPane { self.request_redraw(); } + /// Replace the composer text with `text`. + pub(crate) fn set_composer_text_with_pending_pastes( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + pending_pastes: Vec<(String, String)>, + ) { + self.composer.set_text_content_with_pending_pastes( + text, + text_elements, + local_image_paths, + pending_pastes, + ); + self.request_redraw(); + } + #[allow(dead_code)] pub(crate) fn set_composer_input_enabled( &mut self, @@ -602,6 +625,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(); @@ -799,13 +827,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(); 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..852d6b310b4 --- /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,13 @@ +--- +source: tui/src/bottom_pane/stash_indicator.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 10, height: 1 }, + content: [ + " ⬇ Stashed", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + ] +} 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..6b192f652bd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/stash_indicator.rs @@ -0,0 +1,72 @@ +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::render::renderable::Renderable; + +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(()); + } + + Paragraph::new(vec![Line::from(" ⬇ Stashed changes ".dim().italic())]).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 insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::{buffer::Buffer, 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), 1); + } + + #[test] + fn render_wrapped_message() { + let mut stash = StashIndicator::new(); + stash.stash_exists = true; + let width = 10; + 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..e4bbfdf0685 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)); @@ -2695,6 +2704,20 @@ impl ChatWidget { } else { self.queue_user_message(user_message); } + + if let Some(stash) = self.stash.take() { + self.bottom_pane.set_composer_text_with_pending_pastes( + stash.text, + stash.text_elements, + stash + .local_images + .iter() + .map(|img| img.path.clone()) + .collect(), + stash.pending_pastes, + ); + self.refresh_stash_indicator(); + } } InputResult::Queued { text, @@ -2716,6 +2739,11 @@ 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 => {} }, } @@ -3605,6 +3633,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(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d24504578c6..223d72a5efc 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) From 94f803f39ce4ff7d0c6a8e68be7bcfd7b6888115 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 11:38:14 -0300 Subject: [PATCH 02/21] feat: Ctrl+S with a current stash restores it Added word wrapping to the stash indicator and if Ctrl+S is pressed with a stash present, recalls it. --- codex-rs/tui/src/bottom_pane/mod.rs | 66 +++++++++++++++++++ ...__tests__render_stash_wrapped_message.snap | 35 +++++++++- ...nd_queued_messages_and_stash_snapshot.snap | 14 ++++ ...ane__tests__status_and_stash_snapshot.snap | 12 ++++ .../tui/src/bottom_pane/stash_indicator.rs | 12 +++- codex-rs/tui/src/chatwidget.rs | 22 ++++--- 6 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_and_stash_snapshot.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_stash_snapshot.snap diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b4a5fdf09c0..7d3f170398e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -430,6 +430,20 @@ impl BottomPane { self.request_redraw(); } + /// Restores composer text, images and pending pastes from a PreparedDraft + pub(crate) fn restore_stash(&mut self, stash: PreparedDraft) { + self.set_composer_text_with_pending_pastes( + stash.text, + stash.text_elements, + stash + .local_images + .iter() + .map(|img| img.path.clone()) + .collect(), + stash.pending_pastes, + ); + } + #[allow(dead_code)] pub(crate) fn set_composer_input_enabled( &mut self, @@ -1198,6 +1212,58 @@ 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 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 index 852d6b310b4..5cd1082c603 100644 --- 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 @@ -3,11 +3,40 @@ source: tui/src/bottom_pane/stash_indicator.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 10, height: 1 }, + area: Rect { x: 0, y: 0, width: 10, height: 10 }, content: [ - " ⬇ Stashed", + " ↳ Stashe", + " d ", + " (resto", + " res ", + " after ", + " curren", + " t ", + " messag", + " e is ", + " sent) ", ], styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + 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: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 7, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 9, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 8, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 9, y: 9, 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..dc5a237590d --- /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 + + 100% context left · ? for shortcuts 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..27b1a476ea5 --- /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 + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/stash_indicator.rs b/codex-rs/tui/src/bottom_pane/stash_indicator.rs index 6b192f652bd..90ef16eb270 100644 --- a/codex-rs/tui/src/bottom_pane/stash_indicator.rs +++ b/codex-rs/tui/src/bottom_pane/stash_indicator.rs @@ -3,6 +3,7 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use crate::render::renderable::Renderable; +use crate::wrapping::{RtOptions, word_wrap_lines}; pub(crate) struct StashIndicator { pub stash_exists: bool, @@ -20,7 +21,16 @@ impl StashIndicator { return Box::new(()); } - Paragraph::new(vec![Line::from(" ⬇ Stashed changes ".dim().italic())]).into() + 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() } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e4bbfdf0685..52f5e083e41 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2681,6 +2681,17 @@ 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() => { + if let Some(stash) = self.stash.take() { + self.bottom_pane.restore_stash(stash); + self.refresh_stash_indicator(); + return; + } + } _ => match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted { text, @@ -2706,16 +2717,7 @@ impl ChatWidget { } if let Some(stash) = self.stash.take() { - self.bottom_pane.set_composer_text_with_pending_pastes( - stash.text, - stash.text_elements, - stash - .local_images - .iter() - .map(|img| img.path.clone()) - .collect(), - stash.pending_pastes, - ); + self.bottom_pane.restore_stash(stash); self.refresh_stash_indicator(); } } From 491848768112d876d9bd51e1f7dd2d150c95ebdd Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 13:18:38 -0300 Subject: [PATCH 03/21] chore: removed leftover comment --- codex-rs/tui/src/bottom_pane/stash_indicator.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/stash_indicator.rs b/codex-rs/tui/src/bottom_pane/stash_indicator.rs index 90ef16eb270..66dc8e79799 100644 --- a/codex-rs/tui/src/bottom_pane/stash_indicator.rs +++ b/codex-rs/tui/src/bottom_pane/stash_indicator.rs @@ -52,7 +52,6 @@ impl Renderable for StashIndicator { mod tests { use super::*; use insta::assert_snapshot; - // use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::{buffer::Buffer, layout::Rect}; From 6b2da181bd39f91a12ff36a53017fc731e36d31a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 13:19:07 -0300 Subject: [PATCH 04/21] fix: records history only for ForSubmit on prepare_composer_text --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 9d511e4c7fa..01d082be0a4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1926,7 +1926,9 @@ if self.slash_commands_enabled() 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() From a87bc8fcbea6f5367bce305b0521a0a58a90c37a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 13:21:44 -0300 Subject: [PATCH 05/21] feat: shows hint when attempting to restore stash with existing draft --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 81 +++++++++++++++++++ codex-rs/tui/src/bottom_pane/footer.rs | 21 ++++- codex-rs/tui/src/bottom_pane/mod.rs | 15 ++++ codex-rs/tui/src/chatwidget.rs | 37 +++++++-- codex-rs/tui/src/chatwidget/tests.rs | 21 +++++ 5 files changed, 165 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 01d082be0a4..84fdd003e7d 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; @@ -493,6 +494,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) { @@ -2864,6 +2872,14 @@ if matches!(mode, PrepareMode::ForSubmit) 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 { @@ -7011,4 +7027,69 @@ 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()); + + // Press Enter: should dispatch the command, not submit literal text. + 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 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..a6ab80884af 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -118,13 +118,14 @@ pub(crate) enum FooterMode { ShortcutOverlay, /// Transient "press Esc again" hint shown after the first Esc while idle. EscHint, - /// Base single-line footer when the composer is empty. +/// Base single-line footer when the composer is empty. ComposerEmpty, /// Base single-line footer when the composer contains a draft. /// /// 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, @@ -563,6 +573,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 +654,12 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { } } +fn show_stash_preserved_hint_line() -> Line<'static> { + Line::from(vec![ + "Stash preserved — 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(""); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7d3f170398e..3e41ba55734 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -562,6 +562,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) { @@ -660,6 +665,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 } @@ -864,6 +874,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 { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 52f5e083e41..d166aa5929c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2686,9 +2686,8 @@ impl ChatWidget { modifiers: KeyModifiers::CONTROL, .. } if self.bottom_pane.no_modal_or_popup_active() && self.stash.is_some() => { - if let Some(stash) = self.stash.take() { - self.bottom_pane.restore_stash(stash); - self.refresh_stash_indicator(); + tracing::info!("Ctrl+S pressed - attempting to restore stash"); + if self.restore_stash() { return; } } @@ -2716,10 +2715,9 @@ impl ChatWidget { self.queue_user_message(user_message); } - if let Some(stash) = self.stash.take() { - self.bottom_pane.restore_stash(stash); - self.refresh_stash_indicator(); - } + // Only attempts to restore stash if the composer is still empty and has no + // attachments/pending pastes + self.restore_stash(); } InputResult::Queued { text, @@ -2751,8 +2749,22 @@ impl ChatWidget { } } + 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(); } @@ -5791,6 +5803,15 @@ 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(); + } + + pub(crate) fn clear_stash_hint(&mut self) { + self.bottom_pane.clear_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 223d72a5efc..1465a51f304 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -5008,3 +5008,24 @@ 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() + ); +} From 3ec83bc4718aa9a63eebb52b6b95bad3afabea90 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 13:24:08 -0300 Subject: [PATCH 06/21] fix: avoid cloning every local image when stashing --- codex-rs/tui/src/bottom_pane/mod.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3e41ba55734..7ec720b9001 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -435,11 +435,7 @@ impl BottomPane { self.set_composer_text_with_pending_pastes( stash.text, stash.text_elements, - stash - .local_images - .iter() - .map(|img| img.path.clone()) - .collect(), + stash.local_images.into_iter().map(|img| img.path).collect(), stash.pending_pastes, ); } From 4ef23df500f6585f9e24572721012be4623fe168 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 13:26:31 -0300 Subject: [PATCH 07/21] fix: doesn't expand pending pastes when preparing draft to stash --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 84fdd003e7d..a23d80597eb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1848,7 +1848,7 @@ fn record_mention_path(&mut self, insert_text: &str, path: &str) { 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); From 1defa6a81bf97ca0316e23a72e5e11b57463bfea Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 13:28:15 -0300 Subject: [PATCH 08/21] fix: proper removal of clear_stash_hint --- codex-rs/tui/src/chatwidget.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d166aa5929c..a1273002392 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5808,10 +5808,6 @@ impl ChatWidget { self.bottom_pane.show_stash_hint(); } - pub(crate) fn clear_stash_hint(&mut self) { - self.bottom_pane.clear_stash_hint(); - } - /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&mut self, op: Op) { // Record outbound operation for session replay fidelity. From a72494c957e6e19d8e9b4d7b165191bd90e023ed Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 24 Jan 2026 13:37:22 -0300 Subject: [PATCH 09/21] chore: removed leftover trace --- codex-rs/tui/src/chatwidget.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a1273002392..374d4893781 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2686,7 +2686,6 @@ impl ChatWidget { modifiers: KeyModifiers::CONTROL, .. } if self.bottom_pane.no_modal_or_popup_active() && self.stash.is_some() => { - tracing::info!("Ctrl+S pressed - attempting to restore stash"); if self.restore_stash() { return; } From 468a866ea6e64e630ee9534bfe86e04d373671cb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 26 Jan 2026 10:43:09 -0300 Subject: [PATCH 10/21] test(tui): correct expectation for stash message after wrapping --- codex-rs/tui/src/bottom_pane/stash_indicator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/stash_indicator.rs b/codex-rs/tui/src/bottom_pane/stash_indicator.rs index 66dc8e79799..8d77b05edad 100644 --- a/codex-rs/tui/src/bottom_pane/stash_indicator.rs +++ b/codex-rs/tui/src/bottom_pane/stash_indicator.rs @@ -65,7 +65,7 @@ mod tests { fn desired_height_stash() { let mut stash = StashIndicator::new(); stash.stash_exists = true; - assert_eq!(stash.desired_height(40), 1); + assert_eq!(stash.desired_height(40), 2); } #[test] From 9ac7b0141228f5198eb22cce98091f28e30525b8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 26 Jan 2026 10:44:20 -0300 Subject: [PATCH 11/21] style(tui): fix import style --- codex-rs/tui/src/bottom_pane/stash_indicator.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/stash_indicator.rs b/codex-rs/tui/src/bottom_pane/stash_indicator.rs index 8d77b05edad..a6d86be3ec4 100644 --- a/codex-rs/tui/src/bottom_pane/stash_indicator.rs +++ b/codex-rs/tui/src/bottom_pane/stash_indicator.rs @@ -3,7 +3,8 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use crate::render::renderable::Renderable; -use crate::wrapping::{RtOptions, word_wrap_lines}; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; pub(crate) struct StashIndicator { pub stash_exists: bool, @@ -53,7 +54,8 @@ mod tests { use super::*; use insta::assert_snapshot; use pretty_assertions::assert_eq; - use ratatui::{buffer::Buffer, layout::Rect}; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; #[test] fn desired_height_no_stash() { From 933ceda940b591d02c7b664c6d7f0ba525f3f442 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 26 Jan 2026 10:46:31 -0300 Subject: [PATCH 12/21] test(tui): uses width 20 when testing stash to avoid spelling errors --- ...__tests__render_stash_wrapped_message.snap | 36 +++++-------------- .../tui/src/bottom_pane/stash_indicator.rs | 2 +- 2 files changed, 10 insertions(+), 28 deletions(-) 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 index 5cd1082c603..578c98573e4 100644 --- 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 @@ -3,40 +3,22 @@ source: tui/src/bottom_pane/stash_indicator.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 10, height: 10 }, + area: Rect { x: 0, y: 0, width: 20, height: 4 }, content: [ - " ↳ Stashe", - " d ", - " (resto", - " res ", - " after ", - " curren", - " t ", - " messag", - " e is ", - " sent) ", + " ↳ 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: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + 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: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + 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: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + 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: 7, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 9, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 5, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 8, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 9, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 12, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/stash_indicator.rs b/codex-rs/tui/src/bottom_pane/stash_indicator.rs index a6d86be3ec4..d1019454578 100644 --- a/codex-rs/tui/src/bottom_pane/stash_indicator.rs +++ b/codex-rs/tui/src/bottom_pane/stash_indicator.rs @@ -74,7 +74,7 @@ mod tests { fn render_wrapped_message() { let mut stash = StashIndicator::new(); stash.stash_exists = true; - let width = 10; + 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); From ab1fc0874440c2610f3c8c1795add01b9570a907 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 29 Jan 2026 13:53:17 -0300 Subject: [PATCH 13/21] fix: add CantUnstashHint to match exhaustiveness after rebase The rebase from upstream/main introduced new FooterMode variants (ComposerEmpty, ComposerHasDraft) and restructured several match statements. This commit adds the CantUnstashHint variant to all affected match statements and updates snapshots for the new footer layout. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 5 +++++ codex-rs/tui/src/bottom_pane/footer.rs | 4 ++++ ...tests__status_and_queued_messages_and_stash_snapshot.snap | 2 +- ...x_tui__bottom_pane__tests__status_and_stash_snapshot.snap | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a23d80597eb..1ceea76e5e9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2491,6 +2491,7 @@ if matches!(mode, PrepareMode::ForSubmit) 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 @@ -2962,6 +2963,7 @@ impl ChatComposer { FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint + | FooterMode::CantUnstashHint | FooterMode::ComposerHasDraft => false, }; let show_queue_hint = match footer_props.mode { @@ -2971,6 +2973,7 @@ impl ChatComposer { FooterMode::QuitShortcutReminder | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay + | FooterMode::CantUnstashHint | FooterMode::EscHint => false, }; let context_line = context_window_line( @@ -3032,6 +3035,7 @@ impl ChatComposer { } FooterMode::EscHint | FooterMode::QuitShortcutReminder + | FooterMode::CantUnstashHint | FooterMode::ShortcutOverlay => None, } }; @@ -3039,6 +3043,7 @@ impl ChatComposer { footer_props.mode, FooterMode::EscHint | FooterMode::QuitShortcutReminder + | FooterMode::CantUnstashHint | FooterMode::ShortcutOverlay ) { false diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index a6ab80884af..2e9568359ab 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -182,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 { @@ -189,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 @@ -1003,6 +1005,7 @@ mod tests { FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint + | FooterMode::CantUnstashHint | FooterMode::ComposerHasDraft => false, }; let show_queue_hint = match props.mode { @@ -1010,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/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 index dc5a237590d..f4538f125d3 100644 --- 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 @@ -11,4 +11,4 @@ expression: "render_snapshot(&pane, area)" › Ask Codex to do anything - 100% context left · ? for shortcuts + ? 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 index 27b1a476ea5..ac5e9b74fe5 100644 --- 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 @@ -9,4 +9,4 @@ expression: "render_snapshot(&pane, area)" › Ask Codex to do anything - 100% context left · ? for shortcuts + ? for shortcuts 100% context left From ff5ae0792025338dab80604e2996f4f3d9f0c6b4 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 30 Jan 2026 10:21:14 -0300 Subject: [PATCH 14/21] chore: line indentations --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1ceea76e5e9..a0398d1f549 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -762,7 +762,7 @@ impl ChatComposer { self.sync_popups(); } -/// Update the placeholder text without changing input enablement. + /// Update the placeholder text without changing input enablement. pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { self.placeholder_text = placeholder; } @@ -1811,7 +1811,7 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } -fn record_mention_path(&mut self, insert_text: &str, path: &str) { + fn record_mention_path(&mut self, insert_text: &str, path: &str) { let Some(name) = Self::mention_name_from_insert_text(insert_text) else { return; }; @@ -1863,7 +1863,7 @@ fn record_mention_path(&mut self, insert_text: &str, path: &str) { text = text.trim().to_string(); text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); -if self.slash_commands_enabled() + 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('/'); @@ -1934,7 +1934,7 @@ if self.slash_commands_enabled() if text.is_empty() && self.attached_images.is_empty() { return None; } -if matches!(mode, PrepareMode::ForSubmit) + if matches!(mode, PrepareMode::ForSubmit) && (!text.is_empty() || !self.attached_images.is_empty()) { let local_image_paths = self From ae5e2dbd38b9d0e41800a49de3ed94cc6a5fc3f5 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 30 Jan 2026 10:47:25 -0300 Subject: [PATCH 15/21] fix(tui): preserve image placeholder mappings in stash restore Refactors set_text_content_with_local_images_and_pending_pastes to accept LocalImageAttachment structs instead of just paths, ensuring placeholder-to-path mappings are preserved when restoring stashed drafts. This prevents images from being lost during stash/unstash operations. - Rewrite method to properly filter and map LocalImageAttachment by placeholders - Remove intermediate wrapper method in BottomPane - Add test coverage for image preservation - Fix doc comment indentation in footer.rs --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 32 ++++++- codex-rs/tui/src/bottom_pane/footer.rs | 2 +- codex-rs/tui/src/bottom_pane/mod.rs | 96 ++++++++++++++----- 3 files changed, 101 insertions(+), 29 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a0398d1f549..07cc9dc29a7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -772,19 +772,41 @@ impl ChatComposer { self.textarea.set_cursor(self.textarea.text().len()); } - /// Replaces the entire composer with `text`, resets cursor and sets pending pastes. - pub(crate) fn set_text_content_with_pending_pastes( + /// Replaces the entire composer with `text`, resets cursor, sets pending pastes, and + /// preserves placeholder-to-path mappings for local images. + pub(crate) fn set_text_content_with_local_images_and_pending_pastes( &mut self, text: String, text_elements: Vec, - local_image_paths: Vec, + local_images: Vec, pending_pastes: Vec<(String, String)>, ) { - self.set_text_content(text, text_elements, local_image_paths); + // 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; - // drops any pending pastes that no longer have placeholders in text self.pending_pastes .retain(|(ph, _)| self.textarea.text().contains(ph)); + self.textarea.set_cursor(0); + self.sync_popups(); } pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e9568359ab..38dbba24d55 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -118,7 +118,7 @@ pub(crate) enum FooterMode { ShortcutOverlay, /// Transient "press Esc again" hint shown after the first Esc while idle. EscHint, -/// Base single-line footer when the composer is empty. + /// Base single-line footer when the composer is empty. ComposerEmpty, /// Base single-line footer when the composer contains a draft. /// diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7ec720b9001..5f40440d85d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -413,31 +413,16 @@ impl BottomPane { self.request_redraw(); } - /// Replace the composer text with `text`. - pub(crate) fn set_composer_text_with_pending_pastes( - &mut self, - text: String, - text_elements: Vec, - local_image_paths: Vec, - pending_pastes: Vec<(String, String)>, - ) { - self.composer.set_text_content_with_pending_pastes( - text, - text_elements, - local_image_paths, - pending_pastes, - ); - self.request_redraw(); - } - /// Restores composer text, images and pending pastes from a PreparedDraft pub(crate) fn restore_stash(&mut self, stash: PreparedDraft) { - self.set_composer_text_with_pending_pastes( - stash.text, - stash.text_elements, - stash.local_images.into_iter().map(|img| img.path).collect(), - stash.pending_pastes, - ); + self.composer + .set_text_content_with_local_images_and_pending_pastes( + stash.text, + stash.text_elements, + stash.local_images, + stash.pending_pastes, + ); + self.request_redraw(); } #[allow(dead_code)] @@ -894,9 +879,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; @@ -1275,6 +1262,69 @@ mod tests { ); } + #[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(), + }; + + 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::(); From 54f66de824014b1ce6a4f4bf4351c9a6790c8318 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 30 Jan 2026 10:48:28 -0300 Subject: [PATCH 16/21] chore(tui): wrong comment on stash test --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 07cc9dc29a7..7500f4f4daf 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7069,7 +7069,6 @@ mod tests { composer.set_text_content("This will be stashed".to_string(), Vec::new(), Vec::new()); - // Press Enter: should dispatch the command, not submit literal text. let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); From 36648ff21afa7d15b39de3be8abda363438eca95 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 30 Jan 2026 10:50:58 -0300 Subject: [PATCH 17/21] fix(tui): hint message when stash is preserved --- codex-rs/tui/src/bottom_pane/footer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 38dbba24d55..21546499d8a 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -658,7 +658,7 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { fn show_stash_preserved_hint_line() -> Line<'static> { Line::from(vec![ - "Stash preserved — clear input and press Ctrl+S to restore".dim(), + "Stash preserved — submit current message to auto-restore, or clear input and press Ctrl+S to restore".dim(), ]) } From fe54c5e0a98528d40f98cbb324dff3d293498811 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 30 Jan 2026 10:56:51 -0300 Subject: [PATCH 18/21] test(tui): stash coverage for pending pastes and the auto-restore path --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 37 +++++++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 20 ++++++++++ 2 files changed, 57 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7500f4f4daf..ec56011c5ea 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7083,6 +7083,43 @@ mod tests { 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 has_draft_detects_text_images_and_pending_pastes() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1465a51f304..089f0bbea96 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -5029,3 +5029,23 @@ async fn stash_indicator() { "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); +} From fd1e85c949e23097d413a6062bea8f07bafbe812 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 30 Jan 2026 10:58:42 -0300 Subject: [PATCH 19/21] docs(tui): PreparedDraft --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ec56011c5ea..ff30db8a3cb 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -257,10 +257,19 @@ enum PrepareMode { } #[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 after any preprocessing (e.g., prompt expansion). 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)>, } pub(crate) struct ChatComposer { From ab6fa0ec9926fe5dbb4f1d7fadb388ef49148dd1 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 30 Jan 2026 20:06:03 -0300 Subject: [PATCH 20/21] fix(tui): clippy errors --- codex-rs/tui/src/chatwidget.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 374d4893781..b2bd79f87b5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2686,9 +2686,7 @@ impl ChatWidget { modifiers: KeyModifiers::CONTROL, .. } if self.bottom_pane.no_modal_or_popup_active() && self.stash.is_some() => { - if self.restore_stash() { - return; - } + self.restore_stash(); } _ => match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted { From 977ddad0a0cde9af322aaeee8662bcc6fdefa29c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 30 Jan 2026 20:51:55 -0300 Subject: [PATCH 21/21] fix(tui): copilot review feedback - Skip prompt expansion when stashing drafts - Keep mention-path mappings in stashed drafts - Image pruning was gated behind slash_commands_enabled --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 77 +++++++++++++++++-- codex-rs/tui/src/bottom_pane/mod.rs | 4 +- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ff30db8a3cb..4aeae8da2d4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -263,7 +263,8 @@ enum PrepareMode { /// image attachments, and any pending paste payloads so the state can be /// restored later. pub(crate) struct PreparedDraft { - /// Composer text after any preprocessing (e.g., prompt expansion). + /// 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, @@ -271,6 +272,8 @@ pub(crate) struct PreparedDraft { 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, @@ -781,14 +784,15 @@ impl ChatComposer { self.textarea.set_cursor(self.textarea.text().len()); } - /// Replaces the entire composer with `text`, resets cursor, sets pending pastes, and - /// preserves placeholder-to-path mappings for local images. + /// 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(""); @@ -814,6 +818,7 @@ impl ChatComposer { 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(); } @@ -1936,7 +1941,8 @@ impl ChatComposer { } } - 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, @@ -1959,9 +1965,11 @@ 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; } @@ -1983,12 +1991,14 @@ impl ChatComposer { self.pending_pastes.clear(); let local_images = self.local_images(); + let mention_paths = self.mention_paths.clone(); Some(PreparedDraft { text, text_elements, local_images, pending_pastes, + mention_paths, }) } @@ -7129,6 +7139,59 @@ mod tests { 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::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5f40440d85d..ae6ed81ed59 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -413,7 +413,7 @@ impl BottomPane { self.request_redraw(); } - /// Restores composer text, images and pending pastes from a PreparedDraft + /// 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( @@ -421,6 +421,7 @@ impl BottomPane { stash.text_elements, stash.local_images, stash.pending_pastes, + stash.mention_paths, ); self.request_redraw(); } @@ -1305,6 +1306,7 @@ mod tests { }, ], pending_pastes: Vec::new(), + mention_paths: HashMap::new(), }; pane.restore_stash(stash);