mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
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
This commit is contained in:
committed by
copyberry
parent
40b0409aa1
commit
eabb7c91d1
@@ -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::<Vec<_>>()
|
||||
.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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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 [
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/app/tests/new_session_tests.rs
|
||||
assertion_line: 55
|
||||
expression: composer_lines
|
||||
---
|
||||
› new line
|
||||
saved line
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<usize>) {
|
||||
self.kill_range_with_kind(range, KillBufferKind::Characterwise);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user