diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c012ef75a..4458b7bc7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -43,6 +43,11 @@ //! - Prunes local attached images so only placeholders that survive expansion are sent. //! - Preserves remote image URLs as separate attachments even when text is empty. //! +//! When these paths clear the visible textarea after a successful submit or slash-command +//! dispatch, they intentionally preserve the textarea kill buffer. That lets users `Ctrl+K` part +//! of a draft, perform a composer action such as changing reasoning level, and then `Ctrl+Y` the +//! killed text back into the now-empty draft. +//! //! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion //! and attachment pruning, and clears pending paste state on success. //! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so @@ -6388,6 +6393,78 @@ mod tests { assert!(composer.textarea.is_empty(), "composer should be cleared"); } + #[test] + fn kill_buffer_persists_after_submit() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + 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_steer_enabled(true); + composer.textarea.insert_str("restore me"); + composer.textarea.set_cursor(0); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert!(composer.textarea.is_empty()); + + composer.textarea.insert_str("hello"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + assert!(composer.textarea.is_empty()); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(composer.textarea.text(), "restore me"); + } + + #[test] + fn kill_buffer_persists_after_slash_command_dispatch() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + 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.textarea.insert_str("restore me"); + composer.textarea.set_cursor(0); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert!(composer.textarea.is_empty()); + + composer.textarea.insert_str("/diff"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "diff"); + } + _ => panic!("expected Command result for '/diff'"), + } + assert!(composer.textarea.is_empty()); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(composer.textarea.text(), "restore me"); + } + #[test] fn slash_command_disabled_while_task_running_keeps_text() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index fbb30a032..81ff50283 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1,3 +1,15 @@ +//! The textarea owns editable composer text, placeholder elements, cursor/wrap state, and a +//! single-entry kill buffer. +//! +//! Whole-buffer replacement APIs intentionally rebuild only the visible draft state. They clear +//! element ranges and derived cursor/wrapping caches, but they keep the kill buffer intact so a +//! caller can clear or rewrite the draft and still allow `Ctrl+Y` to restore the user's most +//! recent `Ctrl+K`. This is the contract higher-level composer flows rely on after submit, +//! slash-command dispatch, and other synthetic clears. +//! +//! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most +//! recent killed span. + use crate::key_hint::is_altgr; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement as UserTextElement; @@ -38,6 +50,14 @@ pub(crate) struct TextElementSnapshot { pub(crate) text: String, } +/// `TextArea` is the editable buffer behind the TUI composer. +/// +/// It owns the raw UTF-8 text, placeholder-like text elements that must move atomically with +/// edits, cursor/wrapping state for rendering, and a single-entry kill buffer for `Ctrl+K` / +/// `Ctrl+Y` style editing. Callers may replace the entire visible buffer through +/// [`Self::set_text_clearing_elements`] or [`Self::set_text_with_elements`] without disturbing the +/// kill buffer; if they incorrectly assume those methods fully reset editing state, a later yank +/// will appear to restore stale text from the user's perspective. #[derive(Debug)] pub(crate) struct TextArea { text: String, @@ -74,12 +94,21 @@ impl TextArea { } } - /// Replace the textarea text and clear any existing text elements. + /// Replace the visible textarea text and clear any existing text elements. + /// + /// This is the "fresh buffer" path for callers that want plain text with no placeholder + /// ranges. It intentionally preserves the current kill buffer, because higher-level flows such + /// as submit or slash-command dispatch clear the draft through this method and still want + /// `Ctrl+Y` to recover the user's most recent kill. pub fn set_text_clearing_elements(&mut self, text: &str) { self.set_text_inner(text, None); } - /// Replace the textarea text and set the provided text elements. + /// Replace the visible textarea text and rebuild the provided text elements. + /// + /// As with [`Self::set_text_clearing_elements`], this resets only state derived from the + /// visible buffer. The kill buffer survives so callers restoring drafts or external edits do + /// not silently discard a pending yank target. pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) { self.set_text_inner(text, Some(elements)); } @@ -109,10 +138,11 @@ impl TextArea { self.elements.sort_by_key(|e| e.range.start); } // Stage 3: clamp the cursor and reset derived state tied to the prior content. + // The kill buffer is editing history rather than visible-buffer state, so full-buffer + // replacements intentionally leave it alone. self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.wrap_cache.replace(None); self.preferred_col = None; - self.kill_buffer.clear(); } pub fn text(&self) -> &str { @@ -552,6 +582,12 @@ impl TextArea { } } + /// Kill from the cursor to the end of the current logical line. + /// + /// If the cursor is already at end-of-line and a trailing newline exists, this kills that + /// newline so repeated invocations continue making progress. The removed text becomes the next + /// yank target and remains available even if a caller later clears or rewrites the visible + /// buffer via `set_text_*`. pub fn kill_to_end_of_line(&mut self) { let eol = self.end_of_current_line(); let range = if self.cursor_pos == eol { @@ -582,6 +618,11 @@ impl TextArea { } } + /// Insert the most recently killed text at the cursor. + /// + /// This uses the textarea's single-entry kill buffer. Because whole-buffer replacement APIs do + /// not clear that buffer, `yank` can restore text after composer-level clears such as submit + /// and slash-command dispatch. pub fn yank(&mut self) { if self.kill_buffer.is_empty() { return; @@ -1735,6 +1776,21 @@ mod tests { assert_eq!(t.cursor(), 5); } + #[test] + fn kill_buffer_persists_across_set_text() { + let mut t = ta_with("restore me"); + t.set_cursor(0); + t.kill_to_end_of_line(); + assert!(t.text().is_empty()); + + t.set_text_clearing_elements("/diff"); + t.set_text_clearing_elements(""); + t.yank(); + + assert_eq!(t.text(), "restore me"); + assert_eq!(t.cursor(), "restore me".len()); + } + #[test] fn cursor_left_and_right_handle_graphemes() { let mut t = ta_with("a👍b"); diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 40e23df3e..5630b84ec 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -118,6 +118,12 @@ the input starts with `!` (shell command). The same preparation path is reused for slash commands with arguments (for example `/plan` and `/review`) so pasted content and text elements are preserved when extracting args. +The composer also treats the textarea kill buffer as separate editing state from the visible draft. +After submit or slash-command dispatch clears the textarea, the most recent `Ctrl+K` payload is +still available for `Ctrl+Y`. This supports flows where a user kills part of a draft, runs a +composer action such as changing reasoning level, and then yanks that text back into the cleared +draft. + ### Numeric auto-submit path When the slash popup is open and the first line matches a numeric-only custom prompt with