diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 44fe24fc48..b660725f4c 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1616,6 +1616,60 @@ async fn failed_root_switch_keeps_background_requests_on_the_active_session() -> Ok(()) } +#[tokio::test] +async fn root_switch_preserves_vim_line_yank() -> Result<()> { + let mut app = make_test_app().await; + std::fs::write( + app.local_settings.user_config_path.as_path(), + "[tui]\nresume_cwd = \"session\"\n", + )?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let previous = app_server.start_thread(&app.config).await?; + app.enqueue_primary_thread_session(previous.session, previous.turns) + .await?; + let target_thread_id = ThreadId::from_string( + &app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Target task", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialize target rollout"), + )?; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("saved line"); + for code in [KeyCode::Esc, KeyCode::Char('d'), KeyCode::Char('d')] { + app.chat_widget + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id) + .await?; + + assert_eq!(app.current_displayed_thread_id(), Some(target_thread_id)); + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("new line"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "new line\nsaved line" + ); + let composer_lines = render_bottom_popup(&app.chat_widget, /*width*/ 80) + .lines() + .take(2) + .collect::>() + .join("\n"); + insta::assert_snapshot!(composer_lines, @"› new line\n saved line"); + app_server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn root_switch_loads_local_preferences_from_disk() -> Result<()> { // Keep the large setup and root-switch futures off the test thread's stack. diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 80f20ad549..a8bc026efb 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -477,8 +477,8 @@ impl App { Ok(live_attached) } - /// Replaces the chat widget and re-seeds the new widget's collab metadata from the navigation - /// cache. + /// Replaces the chat widget, carrying the editor yank and re-seeding collab metadata from the + /// navigation cache. /// /// Thread switches reconstruct the `ChatWidget`, which loses the `collab_agent_metadata` map. /// This helper copies every known nickname/role from `AgentNavigationState` into the @@ -509,6 +509,7 @@ impl App { entry.agent_role.clone(), ); } + chat_widget.restore_kill_buffer_snapshot(self.chat_widget.take_kill_buffer_snapshot()); self.chat_widget = chat_widget; self.sync_active_agent_label(); } diff --git a/codex-rs/tui/src/app/tests/new_session_tests.rs b/codex-rs/tui/src/app/tests/new_session_tests.rs index be61a5245d..0ee704d999 100644 --- a/codex-rs/tui/src/app/tests/new_session_tests.rs +++ b/codex-rs/tui/src/app/tests/new_session_tests.rs @@ -1,8 +1,63 @@ //! Replacement starts use server defaults and preserve the current task on failure. use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; +#[tokio::test] +async fn new_session_preserves_vim_line_yank() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let home = tempdir()?; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(home.path().abs()); + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("saved line"); + for code in [KeyCode::Esc, KeyCode::Char('d'), KeyCode::Char('d')] { + app.chat_widget + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + + let (mut server, _requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("new line"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "new line\nsaved line" + ); + let composer_lines = render_bottom_popup(&app.chat_widget, /*width*/ 80) + .lines() + .take(2) + .collect::>() + .join("\n"); + insta::assert_snapshot!(composer_lines); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + #[tokio::test] async fn replacement_uses_server_defaults_and_preserves_explicit_launch_settings() -> Result<()> { for (managed_effort, explicit, expected_model, expected_effort) in [ diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__new_session_tests__new_session_preserves_vim_line_yank.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__new_session_tests__new_session_preserves_vim_line_yank.snap new file mode 100644 index 0000000000..e543c9722f --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__new_session_tests__new_session_preserves_vim_line_yank.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/new_session_tests.rs +assertion_line: 55 +expression: composer_lines +--- +› new line + saved line diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b5639c05e2..9f07dfe018 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -104,7 +104,8 @@ //! 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. +//! killed text back into the now-empty draft. Replacing the chat widget carries that buffer into +//! the fresh composer so Vim yanks survive `/new` and thread switches. //! //! 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. @@ -336,6 +337,7 @@ use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::MentionBinding; +use crate::bottom_pane::textarea::KillBufferSnapshot; use crate::bottom_pane::textarea::TextArea; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; @@ -1419,6 +1421,14 @@ impl ChatComposer { self.draft.textarea.enter_vim_insert_mode(); } + pub(crate) fn take_kill_buffer_snapshot(&mut self) -> KillBufferSnapshot { + self.draft.textarea.take_kill_buffer_snapshot() + } + + pub(crate) fn restore_kill_buffer_snapshot(&mut self, snapshot: KillBufferSnapshot) { + self.draft.textarea.restore_kill_buffer_snapshot(snapshot); + } + /// Restore draft history transferred from the startup composer. pub(crate) fn restore_startup_local_history( &mut self, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8c75f87b39..7cabd7d126 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -222,6 +222,7 @@ pub(crate) use chat_composer::ComposerDraftSnapshot; pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::QueuedInputAction; pub(crate) use chat_composer_history::HistoryEntry; +pub(crate) use textarea::KillBufferSnapshot; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; @@ -591,6 +592,14 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn take_kill_buffer_snapshot(&mut self) -> KillBufferSnapshot { + self.composer.take_kill_buffer_snapshot() + } + + pub(crate) fn restore_kill_buffer_snapshot(&mut self, snapshot: KillBufferSnapshot) { + self.composer.restore_kill_buffer_snapshot(snapshot); + } + pub(crate) fn toggle_vim_enabled(&mut self) -> bool { let enabled = self.composer.toggle_vim_enabled(); if let Some(questions) = &mut self.questions { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index e8900ad606..13f91bdde9 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -174,6 +174,12 @@ enum KillBufferKind { Linewise, } +/// The last editor kill or Vim yank, carried between chat composers in the same TUI session. +pub(crate) struct KillBufferSnapshot { + text: String, + kind: KillBufferKind, +} + impl TextArea { pub fn new() -> Self { let defaults = RuntimeKeymap::defaults(); @@ -1259,6 +1265,18 @@ impl TextArea { self.insert_str(&text); } + pub(crate) fn take_kill_buffer_snapshot(&mut self) -> KillBufferSnapshot { + KillBufferSnapshot { + text: std::mem::take(&mut self.kill_buffer), + kind: self.kill_buffer_kind, + } + } + + pub(crate) fn restore_kill_buffer_snapshot(&mut self, snapshot: KillBufferSnapshot) { + self.kill_buffer = snapshot.text; + self.kill_buffer_kind = snapshot.kind; + } + fn kill_range(&mut self, range: Range) { self.kill_range_with_kind(range, KillBufferKind::Characterwise); } diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index f078146d2a..74dd84314a 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -3,11 +3,20 @@ use std::collections::HashSet; use crate::bottom_pane::ComposerDraftSnapshot; +use crate::bottom_pane::KillBufferSnapshot; use super::user_messages::remap_colliding_paste_placeholders; use super::*; impl ChatWidget { + pub(crate) fn take_kill_buffer_snapshot(&mut self) -> KillBufferSnapshot { + self.bottom_pane.take_kill_buffer_snapshot() + } + + pub(crate) fn restore_kill_buffer_snapshot(&mut self, snapshot: KillBufferSnapshot) { + self.bottom_pane.restore_kill_buffer_snapshot(snapshot); + } + /// Restore the exact draft entered before the fully initialized composer became available. pub(crate) fn restore_startup_draft(&mut self, draft: ComposerDraftSnapshot) { let existing_draft = self.bottom_pane.composer_draft_snapshot();