diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index d40511f342e..1dbddbf630b 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -38,6 +38,10 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. + /// + /// Placeholders are unique within a single composer buffer. This includes both generic + /// text element placeholders (like large paste markers) and image attachment placeholders, + /// enabling exact matching between elements and their backing payloads. placeholder: Option, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5ce5ebafb4d..dd471a11821 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -21,9 +21,10 @@ //! The Up/Down history path is managed by [`ChatComposerHistory`]. It merges: //! //! - Persistent cross-session history (text-only; no element ranges or attachments). -//! - Local in-session history (full text + text elements + local image paths). +//! - Local in-session history (full text + text elements + local image paths + pending pastes). //! -//! When recalling a local entry, the composer rehydrates text elements and image attachments. +//! When recalling a local entry, the composer rehydrates text elements, pending pastes, and image +//! attachments. //! When recalling a persistent entry, only the text is restored. //! //! # Submission and Prompt Expansion @@ -539,13 +540,9 @@ impl ChatComposer { return false; }; // Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting - // attachments), but local in-session ↑/↓ history can rehydrate elements and image paths. - self.set_text_content_with_mention_bindings( - entry.text, - entry.text_elements, - entry.local_image_paths, - entry.mention_bindings, - ); + // attachments), but local in-session ↑/↓ history can rehydrate elements, image paths, + // mention bindings, and pending large-paste payloads. + self.restore_history_entry(entry); true } @@ -815,6 +812,16 @@ impl ChatComposer { self.sync_popups(); } + fn restore_history_entry(&mut self, entry: HistoryEntry) { + self.set_text_content_with_mention_bindings( + entry.text, + entry.text_elements, + entry.local_image_paths, + entry.mention_bindings, + ); + self.pending_pastes = entry.pending_pastes; + } + /// Update the placeholder text without changing input enablement. pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { self.placeholder_text = placeholder; @@ -832,6 +839,7 @@ impl ChatComposer { } let previous = self.current_text(); let text_elements = self.textarea.text_elements(); + let pending_pastes = self.pending_pastes.clone(); let local_image_paths = self .attached_images .iter() @@ -845,6 +853,7 @@ impl ChatComposer { text_elements, local_image_paths, mention_bindings, + pending_pastes, }); Some(previous) } @@ -2065,6 +2074,7 @@ impl ChatComposer { text_elements: text_elements.clone(), local_image_paths, mention_bindings: original_mention_bindings, + pending_pastes: Vec::new(), }); } self.pending_pastes.clear(); @@ -2352,12 +2362,7 @@ impl ChatComposer { _ => unreachable!(), }; if let Some(entry) = replace_entry { - self.set_text_content_with_mention_bindings( - entry.text, - entry.text_elements, - entry.local_image_paths, - entry.mention_bindings, - ); + self.restore_history_entry(entry); return (InputResult::None, true); } } @@ -5727,6 +5732,55 @@ mod tests { assert_eq!(composer.local_image_paths(), vec![path]); } + #[test] + fn history_navigation_restores_large_paste_payloads_after_ctrl_c() { + 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); + + let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large.clone()); + assert_eq!(composer.pending_pastes.len(), 1); + let placeholder = composer.pending_pastes[0].0.clone(); + assert_eq!(composer.current_text(), placeholder); + + composer.clear_for_ctrl_c(); + assert!(composer.is_empty()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + + let text = composer.current_text(); + assert_eq!(text, placeholder); + let text_elements = composer.text_elements(); + assert_eq!(text_elements.len(), 1); + assert_eq!( + text_elements[0].placeholder(&text), + Some(placeholder.as_str()) + ); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].1, large); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, large); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + } + #[test] fn set_text_content_reattaches_images_without_placeholder_metadata() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 6d9322cd194..351612cac2b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -14,6 +14,7 @@ pub(crate) struct HistoryEntry { pub(crate) text_elements: Vec, pub(crate) local_image_paths: Vec, pub(crate) mention_bindings: Vec, + pub(crate) pending_pastes: Vec<(String, String)>, } impl HistoryEntry { @@ -23,6 +24,7 @@ impl HistoryEntry { text_elements: Vec::new(), local_image_paths: Vec::new(), mention_bindings: Vec::new(), + pending_pastes: Vec::new(), } } @@ -40,6 +42,7 @@ impl HistoryEntry { path: mention.path, }) .collect(), + pending_pastes: Vec::new(), } } } @@ -95,7 +98,10 @@ impl ChatComposerHistory { /// Record a message submitted by the user in the current session so it can /// be recalled later. pub fn record_local_submission(&mut self, entry: HistoryEntry) { - if entry.text.is_empty() && entry.local_image_paths.is_empty() { + if entry.text.is_empty() + && entry.local_image_paths.is_empty() + && entry.pending_pastes.is_empty() + { return; } diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index b927e2db448..cd124dc4512 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -51,18 +51,21 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl - When a slash command name is completed and the user types a space, the `/command` token is promoted into a text element so it renders distinctly and edits atomically. -### History navigation (↑/↓) +## History navigation (↑/↓) -Up/Down recall is handled by `ChatComposerHistory` and merges two sources: +`ChatComposerHistory` merges two sources: -- **Persistent history** (cross-session, fetched from `~/.codex/history.jsonl`): text-only. It - does **not** carry text element ranges or local image attachments, so recalling one of these - entries only restores the text. -- **Local history** (current session): stores the full submission payload, including text - elements and local image paths. Recalling a local entry rehydrates placeholders and attachments. +- **Persistent history** (cross-session): text-only entries read from the history log. These do not + include text elements, local image paths, or pending large-paste payloads so the on-disk format + stays backwards-compatible. +- **Local history** (in-session): full composer snapshots captured during the current session + (submitted messages and Ctrl+C-cleared drafts). -This distinction keeps the on-disk history backward compatible and avoids persisting attachments, -while still providing a richer recall experience for in-session edits. +When recalling a local entry, the composer restores: + +- text elements (so placeholders render as styled elements), +- local image attachments (by matching placeholders), +- pending large-paste payloads (so placeholders still expand on submit). ## Config gating for reuse @@ -91,7 +94,6 @@ Key effects when disabled: Built-in slash command availability is centralized in `codex-rs/tui/src/bottom_pane/slash_commands.rs` and reused by both the composer and the command popup so gating stays in sync. - ## Submission flow (Enter/Tab) There are multiple submission paths, but they share the same core rules: @@ -101,6 +103,9 @@ There are multiple submission paths, but they share the same core rules: `handle_submission` calls `prepare_submission_text` for both submit and queue. That method: 1. Expands any pending paste placeholders so element ranges align with the final text. + - Placeholder text is unique within a composer buffer. Both large paste markers and image + attachment placeholders are suffixed as needed (`#2`, `#3`, …), so payloads can match + elements exactly by placeholder text. 2. Trims whitespace and rebases element ranges to the trimmed buffer. 3. Expands `/prompts:` custom prompts: - Named args use key=value parsing.