From eabb7c91d1619010be044cc17b2380ad11c9b651 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Fri, 11 Sep 2026 06:16:46 +0000 Subject: [PATCH] Preserve editor yanks across new sessions and thread switches (#44742) ## Why Starting a new session with `/new` or switching threads replaces the chat composer, losing the last editor kill or Vim yank. ## What changed Transfer the kill buffer to the replacement composer, preserving both its text and whether it is characterwise or linewise. Yanked text remains available to paste within the same TUI session. ## Testing Add regression tests for new sessions and root thread switches that delete a line with Vim `dd`, then paste it with `p` in the replacement composer and verify the text and rendered output. GitOrigin-RevId: edb4790d4de7b7425de6ef6af4e2786f6a4771c1 --- codex-rs/tui/src/app/agents_overview_tests.rs | 54 ++++++++++++++++++ codex-rs/tui/src/app/session_lifecycle.rs | 5 +- .../tui/src/app/tests/new_session_tests.rs | 55 +++++++++++++++++++ ...__new_session_preserves_vim_line_yank.snap | 7 +++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 12 +++- codex-rs/tui/src/bottom_pane/mod.rs | 9 +++ codex-rs/tui/src/bottom_pane/textarea.rs | 18 ++++++ codex-rs/tui/src/chatwidget/input_restore.rs | 9 +++ 8 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__new_session_tests__new_session_preserves_vim_line_yank.snap 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();