From 307cc11b940784e8faf3514c91f038b78d1a286a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Mon, 25 Aug 2025 11:15:40 +0900 Subject: [PATCH] feat: #2616 save chat history as suggestions during the same session --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 78 +++++++++++++++++++ .../src/bottom_pane/chat_composer_history.rs | 13 ++++ 2 files changed, 91 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 0d8a34a809..3aa2d8b13d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -36,6 +36,7 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +use unicode_width::UnicodeWidthChar; // Heuristic thresholds for detecting paste-like input bursts. const PASTE_BURST_MIN_CHARS: u16 = 3; @@ -97,6 +98,7 @@ pub(crate) struct ChatComposer { // Buffer to accumulate characters during a detected non-bracketed paste burst. paste_burst_buffer: String, in_paste_burst_mode: bool, + suggestion: Option, } /// Popup state – at most one can be visible at any time. @@ -136,6 +138,7 @@ impl ChatComposer { paste_burst_until: None, paste_burst_buffer: String::new(), in_paste_burst_mode: false, + suggestion: None, } } @@ -168,6 +171,24 @@ impl ChatComposer { self.textarea.is_empty() } + fn update_suggestion(&mut self) { + if !matches!(self.active_popup, ActivePopup::None) { + self.suggestion = None; + return; + } + let cursor = self.textarea.cursor(); + let text = self.textarea.text(); + if cursor != text.len() || text.is_empty() { + self.suggestion = None; + return; + } + if text.starts_with('/') || text.starts_with('@') { + self.suggestion = None; + return; + } + self.suggestion = self.history.suggest(text); + } + /// Update the cached *context-left* percentage and refresh the placeholder /// text. The UI relies on the placeholder to convey the remaining /// context when the composer is empty. @@ -229,6 +250,7 @@ impl ChatComposer { self.paste_burst_until = None; self.sync_command_popup(); self.sync_file_search_popup(); + self.update_suggestion(); true } @@ -239,6 +261,7 @@ impl ChatComposer { self.textarea.insert_element(&placeholder); self.attached_images .push(AttachedImage { placeholder, path }); + self.update_suggestion(); } pub fn take_recent_submission_images(&mut self) -> Vec { @@ -272,6 +295,7 @@ impl ChatComposer { self.textarea.insert_str(text); self.sync_command_popup(); self.sync_file_search_popup(); + self.update_suggestion(); } /// Handle a key event coming from the main UI. @@ -290,6 +314,7 @@ impl ChatComposer { self.sync_file_search_popup(); } + self.update_suggestion(); result } @@ -603,11 +628,23 @@ impl ChatComposer { self.textarea.set_text(&new_text); let new_cursor = start_idx.saturating_add(path.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); + self.update_suggestion(); } /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { match key_event { + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + .. + } => { + if let Some(s) = self.suggestion.take() { + self.textarea.insert_str(&s); + return (InputResult::None, true); + } + (InputResult::None, false) + } // ------------------------------------------------------------- // History navigation (Up / Down) – only when the composer is not // empty or when the cursor is at the correct position, to avoid @@ -1199,6 +1236,30 @@ impl WidgetRef for &ChatComposer { let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if let (Some(suggestion), Some((cx, cy))) = ( + self.suggestion.as_ref(), + self.textarea.cursor_pos_with_state(textarea_rect, &state), + ) { + let max_width = textarea_rect.x + textarea_rect.width - cx; + if max_width > 0 { + let mut display = String::new(); + let mut width = 0usize; + for ch in suggestion.chars() { + let w = ch.width().unwrap_or(0); + if width + w > max_width as usize { + break; + } + display.push(ch); + width += w; + } + buf.set_string( + cx, + cy, + display, + Style::default().add_modifier(Modifier::DIM), + ); + } + } if self.textarea.text().is_empty() { Line::from(self.placeholder_text.as_str()) .style(Style::default().dim()) @@ -1887,4 +1948,21 @@ mod tests { "one image mapping remains" ); } + + #[test] + fn suggests_from_history_and_accepts_with_tab() { + 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()); + composer + .history + .record_local_submission("now run the tests"); + composer.textarea.set_text("now run"); + composer.textarea.set_cursor("now run".len()); + composer.update_suggestion(); + assert_eq!(composer.suggestion.as_deref(), Some(" the tests")); + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "now run the tests"); + } } 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 87bcc438e9..c4464d34ca 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -135,6 +135,19 @@ impl ChatComposerHistory { } } + /// Return the tail of the most recent history entry that starts with `prefix`. + pub(crate) fn suggest(&self, prefix: &str) -> Option { + if prefix.is_empty() { + return None; + } + self.local_history + .iter() + .rev() + .chain(self.fetched_history.values()) + .find(|entry| entry.starts_with(prefix) && entry.len() > prefix.len()) + .map(|entry| entry[prefix.len()..].to_string()) + } + /// Integrate a GetHistoryEntryResponse event. pub fn on_entry_response( &mut self,