mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Add Vim undo to the TUI composer (#41941)
## Why Composer drafts include attachments, mention targets, and deferred paste payloads in addition to visible text. Vim undo needs to restore that complete state as a single edit. ## What changed - Add bounded, draft-level Vim undo history, with `u` as the configurable default binding in normal mode. - Group complete Vim commands and insert sessions into undo steps, including direct composer changes such as pastes and attachments. - Preserve undo, repeat, and search state when a reverse-history preview is canceled, and start fresh history when another prompt is accepted. ## Testing - Cover grouped edits, custom bindings, history and search interactions, attachments, and large paste payloads. GitOrigin-RevId: 12ecdb3c34a2d66f5d856aa2f82dc15bd91b230b
This commit is contained in:
committed by
copyberry
parent
c5a3700dd7
commit
9c7edd4bc3
@@ -269,6 +269,8 @@ pub struct TuiVimNormalKeymap {
|
||||
pub start_yank_operator: Option<KeybindingsSpec>,
|
||||
/// Begin change operator; next keys select a text object.
|
||||
pub start_change_operator: Option<KeybindingsSpec>,
|
||||
/// Undo the last complete edit (`u`).
|
||||
pub undo: Option<KeybindingsSpec>,
|
||||
/// Cancel a pending operator and return to normal mode.
|
||||
pub cancel_operator: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
@@ -3902,6 +3902,7 @@
|
||||
"substitute_char": null,
|
||||
"till_backward": null,
|
||||
"till_forward": null,
|
||||
"undo": null,
|
||||
"yank_line": null
|
||||
},
|
||||
"vim_operator": {
|
||||
@@ -4728,6 +4729,7 @@
|
||||
"substitute_char": null,
|
||||
"till_backward": null,
|
||||
"till_forward": null,
|
||||
"undo": null,
|
||||
"yank_line": null
|
||||
}
|
||||
},
|
||||
@@ -5246,6 +5248,14 @@
|
||||
],
|
||||
"description": "Stop before the next character on the current line (`t`)."
|
||||
},
|
||||
"undo": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Undo the last complete edit (`u`)."
|
||||
},
|
||||
"yank_line": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1446,7 +1446,7 @@ max_recent_non_user_entries = 8
|
||||
.latest_scored_tool_call
|
||||
.load(Ordering::Acquire)
|
||||
== 0
|
||||
|| metrics.0.lock().unwrap().len() < 10
|
||||
|| metrics.0.lock().unwrap().len() < 14
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
(Some(ThreadId::new()), true),
|
||||
] {
|
||||
let (mut app, mut events, _) = make_test_app_with_channels().await;
|
||||
app.keymap.app.open_agents = vec![crate::key_hint::alt(KeyCode::Char('a'))];
|
||||
let child = ThreadId::new();
|
||||
let child_recap = recap::RecapProgress {
|
||||
completed_turns: 3,
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the
|
||||
//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as
|
||||
//! an editable draft and `Esc` restores the draft that was active when search started.
|
||||
//! Vim undo snapshots the complete draft and groups direct edits with active Vim transactions.
|
||||
//! Canceled history previews restore both; accepting another prompt resets undo history.
|
||||
//! Vim queries stay draft-local.
|
||||
//!
|
||||
//! Slash commands are staged for local history instead of being recorded immediately. Command
|
||||
@@ -303,6 +305,7 @@ mod history_search;
|
||||
mod popup_state;
|
||||
mod reconnect;
|
||||
mod slash_input;
|
||||
mod vim_history;
|
||||
mod vim_search;
|
||||
|
||||
use self::attachment_state::AttachmentState;
|
||||
@@ -316,6 +319,7 @@ use self::popup_state::PopupState;
|
||||
use self::slash_input::SlashInput;
|
||||
use self::slash_input::SlashValidation;
|
||||
use self::slash_input::SubmissionValidation;
|
||||
use self::vim_history::VimHistory;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -535,6 +539,7 @@ pub(crate) struct ChatComposer {
|
||||
windows_degraded_sandbox_active: bool,
|
||||
side_conversation_active: bool,
|
||||
history_search: Option<HistorySearchSession>,
|
||||
vim_history: VimHistory,
|
||||
submit_keys: Vec<KeyBinding>,
|
||||
queue_keys: Vec<KeyBinding>,
|
||||
toggle_shortcuts_keys: Vec<KeyBinding>,
|
||||
@@ -709,6 +714,7 @@ impl ChatComposer {
|
||||
windows_degraded_sandbox_active: false,
|
||||
side_conversation_active: false,
|
||||
history_search: None,
|
||||
vim_history: VimHistory::default(),
|
||||
submit_keys: vec![key_hint::plain(KeyCode::Enter)],
|
||||
queue_keys: vec![key_hint::plain(KeyCode::Tab)],
|
||||
toggle_shortcuts_keys: vec![
|
||||
@@ -1212,6 +1218,7 @@ impl ChatComposer {
|
||||
query.editor.insert_str(&pasted);
|
||||
return true;
|
||||
}
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
let char_count = pasted.chars().count();
|
||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
let placeholder = self.next_large_paste_placeholder(char_count);
|
||||
@@ -1229,6 +1236,9 @@ impl ChatComposer {
|
||||
}
|
||||
self.draft.paste_burst.clear_after_explicit_paste();
|
||||
self.sync_popups();
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1290,6 +1300,7 @@ impl ChatComposer {
|
||||
/// are renumbered to `[Image #M+1]..[Image #N]` (where `M` is the number of
|
||||
/// remote images). Cursor is placed at the end after rebuilding elements.
|
||||
pub(crate) fn apply_external_edit(&mut self, text: String) {
|
||||
self.vim_history = VimHistory::default();
|
||||
self.draft.pending_pastes.clear();
|
||||
let (text, _) = self.imported_text_for_textarea(text, Vec::new());
|
||||
|
||||
@@ -1375,6 +1386,7 @@ impl ChatComposer {
|
||||
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
|
||||
self.draft.textarea.enable_vim_search();
|
||||
self.draft.textarea.set_vim_enabled(enabled);
|
||||
self.vim_history = VimHistory::default();
|
||||
self.draft.paste_burst.clear_after_explicit_paste();
|
||||
self.footer.mode = reset_mode_after_activity(self.footer.mode);
|
||||
}
|
||||
@@ -1565,6 +1577,7 @@ impl ChatComposer {
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
) {
|
||||
// Clear any existing content, placeholders, and attachments first.
|
||||
self.vim_history = VimHistory::default();
|
||||
self.draft.textarea.set_text_clearing_elements("");
|
||||
self.draft.is_bash_mode = false;
|
||||
self.draft.pending_pastes.clear();
|
||||
@@ -1833,8 +1846,12 @@ impl ChatComposer {
|
||||
|
||||
/// Insert an attachment placeholder and track it for the next submission.
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
self.attachments
|
||||
.attach_image(&mut self.draft.textarea, path);
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1960,9 +1977,13 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
self.draft.textarea.insert_str(text);
|
||||
self.sync_bash_mode_from_text();
|
||||
self.sync_popups();
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event coming from the main UI.
|
||||
@@ -1986,6 +2007,10 @@ impl ChatComposer {
|
||||
return self.handle_history_search_key(key_event);
|
||||
}
|
||||
|
||||
if self.handle_vim_history_key(key_event) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) {
|
||||
return self.begin_history_search();
|
||||
}
|
||||
@@ -2491,6 +2516,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
fn insert_selected_file_path(&mut self, token_range: Range<usize>, selected_path: &str) {
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
let token_range = self.separate_completion_from_adjacent_element(token_range);
|
||||
if Self::is_image_path(selected_path) {
|
||||
let path_buf = PathBuf::from(selected_path);
|
||||
@@ -2511,6 +2537,9 @@ impl ChatComposer {
|
||||
} else {
|
||||
self.insert_selected_path(token_range, selected_path);
|
||||
}
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_text_elements(
|
||||
@@ -3136,6 +3165,7 @@ impl ChatComposer {
|
||||
| InputResult::ServiceTierCommand(_)
|
||||
| InputResult::CommandWithArgs(_, _, _)
|
||||
) {
|
||||
self.vim_history = VimHistory::default();
|
||||
self.draft.textarea.enter_vim_insert_mode();
|
||||
}
|
||||
}
|
||||
@@ -3457,8 +3487,16 @@ impl ChatComposer {
|
||||
&mut self,
|
||||
key_event: &KeyEvent,
|
||||
) -> Option<(InputResult, bool)> {
|
||||
self.attachments
|
||||
.handle_remote_image_selection_key(key_event, &mut self.draft.textarea)
|
||||
let removes_remote_image = matches!(key_event.code, KeyCode::Delete | KeyCode::Backspace)
|
||||
&& self.attachments.selected_remote_image_index.is_some();
|
||||
let started_vim_edit = removes_remote_image && self.begin_direct_vim_edit();
|
||||
let result = self
|
||||
.attachments
|
||||
.handle_remote_image_selection_key(key_event, &mut self.draft.textarea);
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
@@ -3635,6 +3673,9 @@ impl ChatComposer {
|
||||
input: KeyEvent,
|
||||
now: Instant,
|
||||
) -> (InputResult, bool) {
|
||||
if !self.draft.textarea.is_vim_normal_mode() {
|
||||
self.begin_vim_edit(input);
|
||||
}
|
||||
// If we have a buffered non-bracketed paste burst and enough time has
|
||||
// elapsed since the last char, flush it before handling a new input.
|
||||
self.handle_paste_burst_flush(now);
|
||||
@@ -3744,16 +3785,22 @@ impl ChatComposer {
|
||||
&& matches!(input.code, KeyCode::Backspace)
|
||||
&& self.draft.textarea.cursor() == 0
|
||||
{
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
self.draft.is_bash_mode = false;
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
self.begin_vim_edit(input);
|
||||
self.draft.textarea.input(input);
|
||||
self.sync_bash_mode_from_text();
|
||||
|
||||
if let Some(elements_before) = elements_before {
|
||||
self.reconcile_deleted_elements(elements_before);
|
||||
}
|
||||
self.finish_vim_edit();
|
||||
|
||||
// Update the paste-burst heuristic for text, shortcut, and non-char events.
|
||||
match input.code {
|
||||
@@ -10663,6 +10710,8 @@ mod tests {
|
||||
|
||||
let (mut composer, _rx) = new_test_composer();
|
||||
configure_bound_plugin_left_of_unbound_plugin(&mut composer);
|
||||
composer.set_vim_enabled(/*enabled*/ true);
|
||||
let before = composer.draft_snapshot();
|
||||
composer.insert_selected_file_path(
|
||||
"@sample".len().."@sample@other".len(),
|
||||
image_path.to_str().expect("UTF-8 path"),
|
||||
@@ -10670,6 +10719,9 @@ mod tests {
|
||||
|
||||
assert_eq!(composer.current_text(), "@sample [Image #1] ");
|
||||
assert_eq!(composer.local_image_paths(), vec![image_path]);
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Composer-side Ctrl+R reverse history search state and rendering helpers.
|
||||
//!
|
||||
//! The persistent and local history stores live in `chat_composer_history`, but the composer owns
|
||||
//! the active search session because it has to snapshot/restore the editable draft, preview matches
|
||||
//! in the textarea, and render the footer prompt while the footer line is acting as the search
|
||||
//! input.
|
||||
//! the active search session because it has to snapshot/restore the editable draft and Vim edit
|
||||
//! state, preview matches in the textarea, and render the footer prompt while the footer line is
|
||||
//! acting as the search input.
|
||||
//!
|
||||
//! This module is responsible for the UI-facing lifecycle of a search session: recognizing the
|
||||
//! keys that enter and drive search mode, keeping the footer query separate from the textarea
|
||||
@@ -35,10 +35,12 @@ use super::super::chat_composer_history::HistorySearchDirection;
|
||||
use super::super::chat_composer_history::HistorySearchResult;
|
||||
use super::super::footer::footer_height;
|
||||
use super::super::footer::reset_mode_after_activity;
|
||||
use super::super::textarea::VimPersistentState;
|
||||
use super::ActivePopup;
|
||||
use super::ChatComposer;
|
||||
use super::ComposerDraft;
|
||||
use super::InputResult;
|
||||
use super::vim_history::VimHistory;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
@@ -49,12 +51,16 @@ use crate::ui_consts::FOOTER_INDENT_COLS;
|
||||
/// Active composer-owned state for one Ctrl+R search interaction.
|
||||
///
|
||||
/// The session is created only by [`ChatComposer::begin_history_search`] and is cleared only by
|
||||
/// accepting, canceling, or replacing the search mode. It stores the original draft separately from
|
||||
/// the footer query so transient previews never destroy the user's in-progress composer content.
|
||||
#[derive(Clone, Debug)]
|
||||
/// accepting, canceling, or replacing the search mode. It stores the original draft and Vim edit
|
||||
/// state separately from the footer query so transient previews never destroy in-progress content.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct HistorySearchSession {
|
||||
/// Draft to restore when search is canceled or a query has no match.
|
||||
original_draft: ComposerDraft,
|
||||
/// Same-draft Vim edits to restore when a temporary preview is canceled.
|
||||
original_vim_history: VimHistory,
|
||||
/// Active and completed Vim commands suspended during temporary draft replacement.
|
||||
original_vim_state: VimPersistentState,
|
||||
/// Footer-owned query text typed while Ctrl+R search is active.
|
||||
query: String,
|
||||
/// User-visible search status used to choose footer hints and composer preview behavior.
|
||||
@@ -114,8 +120,16 @@ impl ChatComposer {
|
||||
}
|
||||
self.popups.active = ActivePopup::None;
|
||||
self.attachments.clear_remote_image_selection();
|
||||
let original_draft = self.snapshot_draft();
|
||||
let original_vim_history = std::mem::take(&mut self.vim_history);
|
||||
let mut original_vim_state = VimPersistentState::default();
|
||||
self.draft
|
||||
.textarea
|
||||
.swap_vim_persistent_state(&mut original_vim_state);
|
||||
self.history_search = Some(HistorySearchSession {
|
||||
original_draft: self.snapshot_draft(),
|
||||
original_draft,
|
||||
original_vim_history,
|
||||
original_vim_state,
|
||||
query: String::new(),
|
||||
status: HistorySearchStatus::Idle,
|
||||
});
|
||||
@@ -292,12 +306,16 @@ impl ChatComposer {
|
||||
/// as Ctrl+C, should use the boolean result to consume the key without also clearing the
|
||||
/// restored draft or triggering quit/interrupt behavior.
|
||||
pub(crate) fn cancel_history_search(&mut self) -> bool {
|
||||
let Some(search) = self.history_search.take() else {
|
||||
let Some(mut search) = self.history_search.take() else {
|
||||
return false;
|
||||
};
|
||||
self.history.reset_navigation();
|
||||
self.footer.mode = reset_mode_after_activity(self.footer.mode);
|
||||
self.restore_draft(search.original_draft);
|
||||
self.vim_history = search.original_vim_history;
|
||||
self.draft
|
||||
.textarea
|
||||
.swap_vim_persistent_state(&mut search.original_vim_state);
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Bounded Vim undo history for the complete composer-owned draft.
|
||||
//!
|
||||
//! A textarea does not own pending paste payloads, image attachments, or mention targets. Keeping
|
||||
//! edit transactions here lets one snapshot restore those values together with visible text. Keys
|
||||
//! and direct composer-owned changes share the same bounded transaction state.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::super::textarea::VimPersistentState;
|
||||
use super::ChatComposer;
|
||||
use super::ComposerDraft;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
const MAX_VIM_UNDO_STEPS: usize = 64;
|
||||
const MAX_VIM_UNDO_BYTES: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct VimHistory {
|
||||
undo: VecDeque<ComposerDraft>,
|
||||
pending: Option<ComposerDraft>,
|
||||
}
|
||||
|
||||
impl ComposerDraft {
|
||||
fn vim_history_bytes(&self) -> usize {
|
||||
let mut bytes = self.text.len()
|
||||
+ self.text_elements.len()
|
||||
* std::mem::size_of::<codex_protocol::user_input::TextElement>();
|
||||
for path in &self.local_image_paths {
|
||||
bytes += path.as_os_str().len();
|
||||
}
|
||||
for url in &self.remote_image_urls {
|
||||
bytes += url.len();
|
||||
}
|
||||
for binding in &self.mention_bindings {
|
||||
bytes += binding.mention.len() + binding.path.len();
|
||||
}
|
||||
for (placeholder, pasted) in &self.pending_pastes {
|
||||
bytes += placeholder.len() + pasted.len();
|
||||
}
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl VimHistory {
|
||||
fn trim(&mut self) {
|
||||
while self.undo.len() > MAX_VIM_UNDO_STEPS
|
||||
|| self
|
||||
.undo
|
||||
.iter()
|
||||
.chain(self.pending.iter())
|
||||
.map(ComposerDraft::vim_history_bytes)
|
||||
.sum::<usize>()
|
||||
> MAX_VIM_UNDO_BYTES
|
||||
{
|
||||
self.undo.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
/// Apply undo only before normal-mode commands begin.
|
||||
pub(super) fn handle_vim_history_key(&mut self, event: KeyEvent) -> bool {
|
||||
if !self.draft.textarea.is_vim_normal_mode()
|
||||
|| self.draft.textarea.is_vim_operator_pending()
|
||||
|| self.popups.active()
|
||||
|| !self.vim_normal_keymap.undo.is_pressed(event)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(snapshot) = self.vim_history.undo.pop_back() {
|
||||
let history = std::mem::take(&mut self.vim_history);
|
||||
let mut vim_state = VimPersistentState::default();
|
||||
self.draft
|
||||
.textarea
|
||||
.swap_vim_persistent_state(&mut vim_state);
|
||||
self.restore_draft(snapshot);
|
||||
self.draft
|
||||
.textarea
|
||||
.swap_vim_persistent_state(&mut vim_state);
|
||||
self.vim_history = history;
|
||||
self.draft.textarea.enter_vim_normal_mode();
|
||||
self.vim_history.trim();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Snapshot only keys that can begin or complete a Vim edit transaction.
|
||||
pub(super) fn begin_vim_edit(&mut self, event: KeyEvent) {
|
||||
if !self.draft.textarea.is_vim_enabled()
|
||||
|| self.vim_history.pending.is_some()
|
||||
|| self.draft.textarea.is_vim_operator_pending()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if self.draft.textarea.is_vim_normal_mode()
|
||||
&& (self.vim_normal_keymap.move_left.is_pressed(event)
|
||||
|| self.vim_normal_keymap.move_right.is_pressed(event)
|
||||
|| self.vim_normal_keymap.move_up.is_pressed(event)
|
||||
|| self.vim_normal_keymap.move_down.is_pressed(event)
|
||||
|| self.vim_normal_keymap.move_word_forward.is_pressed(event)
|
||||
|| self.vim_normal_keymap.move_word_backward.is_pressed(event)
|
||||
|| self.vim_normal_keymap.move_word_end.is_pressed(event)
|
||||
|| self.vim_normal_keymap.move_line_start.is_pressed(event)
|
||||
|| self.vim_normal_keymap.move_line_end.is_pressed(event)
|
||||
|| self.vim_normal_keymap.find_forward.is_pressed(event)
|
||||
|| self.vim_normal_keymap.find_backward.is_pressed(event)
|
||||
|| self.vim_normal_keymap.jump_top.is_pressed(event)
|
||||
|| self.vim_normal_keymap.jump_bottom.is_pressed(event)
|
||||
|| self.draft.textarea.wants_vim_search_key(event)
|
||||
|| self.vim_normal_keymap.yank_line.is_pressed(event)
|
||||
|| self.vim_normal_keymap.start_yank_operator.is_pressed(event)
|
||||
|| self.vim_normal_keymap.cancel_operator.is_pressed(event))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.begin_vim_edit_transaction();
|
||||
}
|
||||
|
||||
/// Start one standalone draft edit without splitting an active Vim command.
|
||||
pub(super) fn begin_direct_vim_edit(&mut self) -> bool {
|
||||
if !self.draft.textarea.is_vim_enabled()
|
||||
|| self.draft.textarea.is_vim_operator_pending()
|
||||
|| self.vim_history.pending.is_some()
|
||||
|| self.history_search.is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.begin_vim_edit_transaction();
|
||||
if self.vim_history.pending.is_none() {
|
||||
return false;
|
||||
}
|
||||
let mut vim_state = VimPersistentState::default();
|
||||
self.draft
|
||||
.textarea
|
||||
.swap_vim_persistent_state(&mut vim_state);
|
||||
vim_state.commands.last_change.clear();
|
||||
self.draft
|
||||
.textarea
|
||||
.swap_vim_persistent_state(&mut vim_state);
|
||||
true
|
||||
}
|
||||
|
||||
fn begin_vim_edit_transaction(&mut self) {
|
||||
let visible_bytes = self.draft.textarea.text().len();
|
||||
let paste_bytes = self
|
||||
.draft
|
||||
.pending_pastes
|
||||
.iter()
|
||||
.map(|(placeholder, pasted)| placeholder.len() + pasted.len())
|
||||
.sum::<usize>();
|
||||
if visible_bytes.saturating_add(paste_bytes) > MAX_VIM_UNDO_BYTES {
|
||||
self.vim_history = VimHistory::default();
|
||||
return;
|
||||
}
|
||||
|
||||
let snapshot = self.snapshot_draft();
|
||||
if snapshot.vim_history_bytes() <= MAX_VIM_UNDO_BYTES {
|
||||
self.vim_history.pending = Some(snapshot);
|
||||
self.vim_history.trim();
|
||||
} else {
|
||||
self.vim_history = VimHistory::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a complete normal-mode command or one insert-mode session.
|
||||
pub(super) fn finish_vim_edit(&mut self) {
|
||||
if !self.draft.textarea.is_vim_normal_mode()
|
||||
|| self.draft.textarea.is_vim_operator_pending()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(snapshot) = self.vim_history.pending.take() else {
|
||||
return;
|
||||
};
|
||||
if snapshot.text == self.current_text()
|
||||
&& snapshot.text_elements == self.current_text_elements()
|
||||
&& snapshot.pending_pastes == self.draft.pending_pastes
|
||||
&& snapshot.local_image_paths == self.attachments.local_image_paths()
|
||||
&& snapshot.remote_image_urls == self.attachments.remote_image_urls()
|
||||
&& snapshot.mention_bindings == self.snapshot_mention_bindings()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.vim_history.undo.push_back(snapshot);
|
||||
self.vim_history.trim();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "vim_history_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Composer undo transactions preserve rich draft data and active Vim commands.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_config::types::KeybindingSpec;
|
||||
use codex_config::types::KeybindingsSpec;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::super::super::chat_composer_history::HistoryEntry;
|
||||
use super::super::ChatComposer;
|
||||
use super::super::InputResult;
|
||||
use super::super::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use super::super::tests::new_test_composer;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
|
||||
fn vim_composer(text: &str) -> ChatComposer {
|
||||
let (mut composer, _receiver) = new_test_composer();
|
||||
composer.set_disable_paste_burst(/*disabled*/ true);
|
||||
composer.set_text_content(text.to_owned(), Vec::new(), Vec::new());
|
||||
composer.set_vim_enabled(/*enabled*/ true);
|
||||
composer.draft.textarea.set_cursor(/*pos*/ 0);
|
||||
composer
|
||||
}
|
||||
|
||||
fn keys(composer: &mut ChatComposer, keys: &str) {
|
||||
for key in keys.chars() {
|
||||
let code = if key == '\n' {
|
||||
KeyCode::Enter
|
||||
} else {
|
||||
KeyCode::Char(key)
|
||||
};
|
||||
let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(composer: &mut ChatComposer) {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn ctrl_r(composer: &mut ChatComposer) {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_groups_complete_vim_edits() {
|
||||
for (original, command, edited) in [
|
||||
("one two", "cwX", "X two"),
|
||||
("abc", "iXYZ", "XYZabc"),
|
||||
("", "i/review", "/review"),
|
||||
("one two", "c/two\nX", "Xtwo"),
|
||||
("one two", "d/two\n", "two"),
|
||||
("abc-def", "df-", "def"),
|
||||
("abc-def", "cf-X", "Xdef"),
|
||||
] {
|
||||
let mut composer = vim_composer(original);
|
||||
keys(&mut composer, command);
|
||||
if composer.popups.active() {
|
||||
escape(&mut composer);
|
||||
}
|
||||
escape(&mut composer);
|
||||
assert_eq!(composer.current_text(), edited);
|
||||
assert_eq!(composer.vim_history.undo.len(), 1);
|
||||
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(
|
||||
(composer.current_text(), composer.cursor()),
|
||||
(original.to_owned(), 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_preserves_repeat_and_pending_operator_bindings() {
|
||||
let mut composer = vim_composer("alpha beta gamma");
|
||||
keys(&mut composer, "dw.u");
|
||||
assert_eq!(composer.current_text(), "beta gamma");
|
||||
keys(&mut composer, "u.");
|
||||
assert_eq!(composer.current_text(), "beta gamma");
|
||||
|
||||
let mut composer = vim_composer("one two");
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.vim_operator.motion_word_forward = vec![crate::key_hint::plain(KeyCode::Char('u'))];
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
keys(&mut composer, "duu");
|
||||
assert_eq!(composer.current_text(), "one two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_preserves_confirmed_vim_search() {
|
||||
let mut composer = vim_composer("alpha beta alpha");
|
||||
keys(&mut composer, "/alpha\n0xun");
|
||||
assert_eq!(
|
||||
(composer.current_text(), composer.cursor()),
|
||||
("alpha beta alpha".to_owned(), 11)
|
||||
);
|
||||
|
||||
keys(&mut composer, "0");
|
||||
composer.handle_paste("Z".to_owned());
|
||||
keys(&mut composer, "n");
|
||||
assert_eq!(
|
||||
(composer.current_text(), composer.cursor()),
|
||||
("Zalpha beta alpha".to_owned(), 12)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn externally_canceled_search_operator_finishes_the_undo_transaction() {
|
||||
let mut composer = vim_composer("abc");
|
||||
keys(&mut composer, "xd/");
|
||||
assert!(composer.cancel_vim_search());
|
||||
keys(&mut composer, "uxu");
|
||||
assert_eq!(composer.current_text(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_mode_explicit_paste_is_its_own_undoable_edit() {
|
||||
let mut composer = vim_composer("abc");
|
||||
keys(&mut composer, "x");
|
||||
composer.handle_paste("Z".to_owned());
|
||||
assert_eq!(composer.current_text(), "Zbc");
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.current_text(), "bc");
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.current_text(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_mode_direct_insert_and_image_attachment_are_undoable() {
|
||||
let mut composer = vim_composer("abc");
|
||||
composer.insert_str("Z");
|
||||
assert_eq!(composer.current_text(), "Zabc");
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.current_text(), "abc");
|
||||
|
||||
let path = PathBuf::from("example.png");
|
||||
composer.attach_image(path.clone());
|
||||
assert_eq!(composer.local_image_paths(), vec![path]);
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.current_text(), "abc");
|
||||
assert!(composer.local_image_paths().is_empty());
|
||||
keys(&mut composer, "\nABC");
|
||||
assert_eq!(composer.current_text(), "ABC");
|
||||
escape(&mut composer);
|
||||
keys(&mut composer, "u");
|
||||
assert!(composer.is_empty());
|
||||
keys(&mut composer, "u");
|
||||
assert!(composer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_selected_remote_image_is_one_undoable_vim_edit() {
|
||||
for key in [KeyCode::Delete, KeyCode::Backspace] {
|
||||
let mut composer = vim_composer("abc");
|
||||
let url = "https://example.com/remote.png".to_owned();
|
||||
composer.set_remote_image_urls(vec![url.clone()]);
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert!(composer.vim_history.pending.is_none());
|
||||
let _ = composer.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
assert!(composer.remote_image_urls().is_empty());
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.remote_image_urls(), vec![url]);
|
||||
assert_eq!(composer.current_text(), "abc");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_edits_join_the_active_vim_insert_transaction() {
|
||||
let mut composer = vim_composer("abc");
|
||||
composer.enable_vim_in_insert_mode();
|
||||
composer.handle_paste("ABC".to_owned());
|
||||
composer.attach_image(PathBuf::from("example.png"));
|
||||
keys(&mut composer, "D");
|
||||
escape(&mut composer);
|
||||
assert_eq!(composer.vim_history.undo.len(), 1);
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.current_text(), "abc");
|
||||
assert!(composer.local_image_paths().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_shell_prefix_is_its_own_undoable_edit() {
|
||||
let mut composer = vim_composer("");
|
||||
keys(&mut composer, "!foo");
|
||||
escape(&mut composer);
|
||||
keys(&mut composer, "0");
|
||||
let before = composer.draft_snapshot();
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert_eq!(composer.current_text(), "foo");
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_reverse_history_preview_preserves_vim_undo_and_repeat() {
|
||||
let mut composer = vim_composer("alpha beta alpha");
|
||||
composer
|
||||
.history
|
||||
.record_local_submission(HistoryEntry::new("archived prompt".to_owned()));
|
||||
keys(&mut composer, "/alpha\n0x");
|
||||
ctrl_r(&mut composer);
|
||||
keys(&mut composer, "archive");
|
||||
assert_eq!(composer.current_text(), "archived prompt");
|
||||
escape(&mut composer);
|
||||
assert_eq!(composer.current_text(), "lpha beta alpha");
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.current_text(), "alpha beta alpha");
|
||||
keys(&mut composer, ".");
|
||||
assert_eq!(composer.current_text(), "lpha beta alpha");
|
||||
keys(&mut composer, "n");
|
||||
assert_eq!(composer.cursor(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_history_preview_preserves_active_vim_commands() {
|
||||
for query in ["", "archive", "missing"] {
|
||||
for (command, edited) in [("iX", "XYalpha beta"), ("cwX", "XY beta")] {
|
||||
let mut composer = vim_composer("alpha beta");
|
||||
composer
|
||||
.history
|
||||
.record_local_submission(HistoryEntry::new("archived prompt".to_owned()));
|
||||
keys(&mut composer, command);
|
||||
ctrl_r(&mut composer);
|
||||
keys(&mut composer, query);
|
||||
escape(&mut composer);
|
||||
keys(&mut composer, "Y");
|
||||
escape(&mut composer);
|
||||
assert_eq!(composer.current_text(), edited);
|
||||
keys(&mut composer, "u.");
|
||||
assert_eq!(composer.current_text(), edited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_reverse_history_preview_starts_a_fresh_vim_edit_history() {
|
||||
let mut composer = vim_composer("abc");
|
||||
composer
|
||||
.history
|
||||
.record_local_submission(HistoryEntry::new("archived prompt".to_owned()));
|
||||
keys(&mut composer, "xiX");
|
||||
ctrl_r(&mut composer);
|
||||
keys(&mut composer, "archive\n");
|
||||
assert_eq!(composer.current_text(), "archived prompt");
|
||||
assert!(!composer.history_search_active());
|
||||
escape(&mut composer);
|
||||
keys(&mut composer, "u.");
|
||||
assert_eq!(composer.current_text(), "archived prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_uses_configured_bindings_and_supports_unbinding() {
|
||||
let mut composer = vim_composer("abc");
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.vim_normal.undo = vec![crate::key_hint::plain(KeyCode::Char('z'))];
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
keys(&mut composer, "xu");
|
||||
assert_eq!(composer.current_text(), "bc");
|
||||
keys(&mut composer, "z");
|
||||
assert_eq!(composer.current_text(), "abc");
|
||||
keymap.vim_normal.undo.clear();
|
||||
composer.set_keymap_bindings(&keymap);
|
||||
keys(&mut composer, "xz");
|
||||
assert_eq!(composer.current_text(), "bc");
|
||||
|
||||
let mut config = TuiKeymap::default();
|
||||
config.composer.history_search_previous =
|
||||
Some(KeybindingsSpec::One(KeybindingSpec("u".to_owned())));
|
||||
composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap());
|
||||
keys(&mut composer, "u");
|
||||
assert!(composer.history_search_active());
|
||||
escape(&mut composer);
|
||||
config.vim_normal.undo = Some(KeybindingsSpec::One(KeybindingSpec("u".to_owned())));
|
||||
composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap());
|
||||
keys(&mut composer, "u");
|
||||
assert!(!composer.history_search_active());
|
||||
|
||||
let mut composer = vim_composer("abc");
|
||||
let mut config = TuiKeymap::default();
|
||||
config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec("u".to_owned())));
|
||||
composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap());
|
||||
assert_eq!(
|
||||
composer
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE))
|
||||
.0,
|
||||
InputResult::Submitted {
|
||||
text: "abc".to_owned(),
|
||||
text_elements: Vec::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_deleted_large_paste_payload() {
|
||||
let mut composer = vim_composer("alpha ");
|
||||
composer.draft.textarea.set_cursor("alpha ".len());
|
||||
keys(&mut composer, "i");
|
||||
let pasted = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
||||
composer.handle_paste(pasted.clone());
|
||||
escape(&mut composer);
|
||||
let before = composer.draft_snapshot();
|
||||
keys(&mut composer, "x");
|
||||
assert_eq!(composer.current_text(), "alpha ");
|
||||
assert!(composer.draft.pending_pastes.is_empty());
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
assert_eq!(
|
||||
composer.current_text_with_pending(),
|
||||
format!("alpha {pasted}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_deleted_image_attachment() {
|
||||
let mut composer = vim_composer("alpha ");
|
||||
composer.draft.textarea.set_cursor("alpha ".len());
|
||||
composer.attach_image(PathBuf::from("example.png"));
|
||||
composer.draft.textarea.set_cursor("alpha ".len());
|
||||
let before = composer.draft_snapshot();
|
||||
keys(&mut composer, "x");
|
||||
assert_eq!(composer.current_text(), "alpha ");
|
||||
assert!(composer.local_image_paths().is_empty());
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_jump_and_search_navigation_never_snapshot_the_draft() {
|
||||
let mut composer = vim_composer("alpha beta\nalpha gamma");
|
||||
for key in ['f', 'F', 'G', '/', '?', 'n', 'N'] {
|
||||
composer.begin_vim_edit(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
|
||||
assert!(
|
||||
composer.vim_history.pending.is_none(),
|
||||
"navigation binding {key:?} snapshotted the draft"
|
||||
);
|
||||
}
|
||||
let (code, modifiers) = composer
|
||||
.vim_normal_keymap
|
||||
.jump_top
|
||||
.first()
|
||||
.expect("native jump-top chord")
|
||||
.parts();
|
||||
composer.begin_vim_edit(KeyEvent::new(code, modifiers));
|
||||
assert!(composer.vim_history.pending.is_none());
|
||||
|
||||
for command in ["llwbh0$", "fb", "Fa", "G", "/beta\nnN", "?alpha\n"] {
|
||||
let text = "alpha beta\nalpha gamma";
|
||||
let mut composer = vim_composer(text);
|
||||
for key in command.chars() {
|
||||
keys(&mut composer, &key.to_string());
|
||||
assert!(
|
||||
composer.vim_history.pending.is_none(),
|
||||
"navigation command {command:?} snapshotted the draft"
|
||||
);
|
||||
assert!(composer.vim_history.undo.is_empty());
|
||||
}
|
||||
assert_eq!(composer.current_text(), text);
|
||||
}
|
||||
composer
|
||||
.history
|
||||
.record_local_submission(HistoryEntry::new("alpha archived".to_owned()));
|
||||
ctrl_r(&mut composer);
|
||||
keys(&mut composer, "alpha");
|
||||
assert_eq!(composer.current_text(), "alpha archived");
|
||||
assert!(composer.vim_history.pending.is_none());
|
||||
assert!(composer.vim_history.undo.is_empty());
|
||||
escape(&mut composer);
|
||||
assert_eq!(composer.current_text(), "alpha beta\nalpha gamma");
|
||||
}
|
||||
@@ -6,7 +6,11 @@ use ratatui::layout::Rect;
|
||||
|
||||
impl ChatComposer {
|
||||
pub(crate) fn cancel_vim_search(&mut self) -> bool {
|
||||
self.draft.textarea.cancel_vim_search()
|
||||
let canceled = self.draft.textarea.cancel_vim_search();
|
||||
if canceled {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
canceled
|
||||
}
|
||||
|
||||
pub(super) fn vim_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
|
||||
@@ -63,6 +63,7 @@ use self::vim_commands::VimAction;
|
||||
use self::vim_commands::VimCommandState;
|
||||
use self::vim_commands::VimEditTarget;
|
||||
use self::vim_commands::VimInsertPosition;
|
||||
pub(crate) use self::vim_commands::VimPersistentState;
|
||||
|
||||
const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ pub(crate) enum VimEdit {
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Vim command recording and searches preserved across same-draft restoration.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct VimPersistentState {
|
||||
pub(crate) commands: VimCommandState,
|
||||
search: crate::vim_search::SearchQuery,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct VimEditorEdit(VimAction);
|
||||
|
||||
@@ -84,14 +91,19 @@ pub(super) enum VimAction {
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct VimCommandState {
|
||||
pub(crate) struct VimCommandState {
|
||||
pub(super) pending_change: Vec<VimEdit>,
|
||||
last_change: Vec<VimEdit>,
|
||||
pub(crate) last_change: Vec<VimEdit>,
|
||||
changed: bool,
|
||||
pub(super) replaying: bool,
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub(crate) fn swap_vim_persistent_state(&mut self, state: &mut VimPersistentState) {
|
||||
std::mem::swap(&mut self.vim_commands, &mut state.commands);
|
||||
std::mem::swap(&mut self.vim_search.last, &mut state.search);
|
||||
}
|
||||
|
||||
pub(crate) fn vim_repeat_actions(&self) -> Option<Vec<VimEdit>> {
|
||||
(!self.vim_commands.last_change.is_empty()).then(|| self.vim_commands.last_change.clone())
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::ops::Range;
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct VimSearch {
|
||||
input: Option<Box<SearchInput>>,
|
||||
last: SearchQuery,
|
||||
pub(super) last: SearchQuery,
|
||||
}
|
||||
|
||||
/// Footer query editor, boxed by its owning textarea to keep the draft and query independent.
|
||||
|
||||
@@ -210,6 +210,7 @@ pub(crate) struct VimNormalKeymap {
|
||||
pub(crate) start_delete_operator: Vec<KeyBinding>,
|
||||
pub(crate) start_yank_operator: Vec<KeyBinding>,
|
||||
pub(crate) start_change_operator: Vec<KeyBinding>,
|
||||
pub(crate) undo: Vec<KeyBinding>,
|
||||
pub(crate) cancel_operator: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
@@ -757,6 +758,7 @@ impl RuntimeKeymap {
|
||||
vim_normal,
|
||||
start_change_operator
|
||||
),
|
||||
undo: resolve_local!(keymap, defaults, vim_normal, undo),
|
||||
cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator),
|
||||
};
|
||||
|
||||
@@ -889,6 +891,7 @@ impl RuntimeKeymap {
|
||||
keymap.vim_normal.start_change_operator.as_ref(),
|
||||
vim_normal.start_change_operator.as_slice(),
|
||||
),
|
||||
(keymap.vim_normal.undo.as_ref(), vim_normal.undo.as_slice()),
|
||||
(
|
||||
keymap.vim_normal.cancel_operator.as_ref(),
|
||||
vim_normal.cancel_operator.as_slice(),
|
||||
@@ -959,7 +962,6 @@ impl RuntimeKeymap {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut vim_operator = VimOperatorKeymap {
|
||||
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
|
||||
yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line),
|
||||
@@ -1350,7 +1352,12 @@ impl RuntimeKeymap {
|
||||
editor,
|
||||
vim_normal,
|
||||
vim_operator,
|
||||
vim_search: VimSearchKeymap::default(),
|
||||
vim_search: VimSearchKeymap {
|
||||
forward: resolve_local!(keymap, defaults, vim_search, forward),
|
||||
backward: resolve_local!(keymap, defaults, vim_search, backward),
|
||||
next: resolve_local!(keymap, defaults, vim_search, next),
|
||||
previous: resolve_local!(keymap, defaults, vim_search, previous),
|
||||
},
|
||||
vim_text_object,
|
||||
pager,
|
||||
list,
|
||||
@@ -1358,6 +1365,23 @@ impl RuntimeKeymap {
|
||||
approval,
|
||||
};
|
||||
|
||||
if keymap.vim_normal.undo.is_none() {
|
||||
let configured: Vec<_> = runtime_action_bindings(&resolved)
|
||||
.filter(|action| {
|
||||
action.id.context.overlaps(KeymapContext::VimNormal)
|
||||
&& bindings::configured_binding_for_action(keymap, action.id)
|
||||
.is_some_and(Option::is_some)
|
||||
})
|
||||
.flat_map(|action| action.bindings.iter().copied())
|
||||
.collect();
|
||||
resolved.vim_normal.undo.retain(|binding| {
|
||||
!configured.contains(binding)
|
||||
&& !resolved.chords.bindings.iter().any(|chord| {
|
||||
chord.action.context.overlaps(KeymapContext::VimNormal)
|
||||
&& chord.chord.prefix.parts() == binding.parts()
|
||||
})
|
||||
});
|
||||
}
|
||||
resolved.configure_vim_search(keymap)?;
|
||||
resolved.validate_conflicts()?;
|
||||
chords::validate_chord_conflicts(&resolved)?;
|
||||
@@ -1534,6 +1558,7 @@ impl RuntimeKeymap {
|
||||
start_delete_operator: default_bindings![plain(KeyCode::Char('d'))],
|
||||
start_yank_operator: default_bindings![plain(KeyCode::Char('y'))],
|
||||
start_change_operator: default_bindings![plain(KeyCode::Char('c'))],
|
||||
undo: default_bindings![plain(KeyCode::Char('u'))],
|
||||
cancel_operator: default_bindings![plain(KeyCode::Esc)],
|
||||
},
|
||||
vim_search: VimSearchKeymap::default(),
|
||||
@@ -2955,6 +2980,47 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_bindings_prune_new_history_defaults() {
|
||||
for (context, action, binding) in [
|
||||
("vim_normal", "move_left", "u"),
|
||||
("vim_normal", "move_left", "u g"),
|
||||
("vim_search", "forward", "u"),
|
||||
("vim_search", "forward", "u g"),
|
||||
("composer", "submit", "u"),
|
||||
("global", "submit", "u"),
|
||||
("global", "queue", "u"),
|
||||
("global", "toggle_shortcuts", "u"),
|
||||
] {
|
||||
let keymap = serde_json::from_value(serde_json::json!({
|
||||
(context): { (action): binding }
|
||||
}))
|
||||
.expect("config should deserialize");
|
||||
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
|
||||
assert_eq!(runtime.vim_normal.undo, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_vim_history_bindings_still_conflict_with_legacy_bindings() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_normal.move_left = Some(one("u"));
|
||||
keymap.vim_normal.undo = Some(one("u"));
|
||||
expect_conflict(&keymap, "move_left", "undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_arrays_unbind_vim_history_actions() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_normal.undo = Some(KeybindingsSpec::Many(vec![]));
|
||||
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
|
||||
assert!(runtime.vim_normal.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
|
||||
@@ -136,12 +136,21 @@ macro_rules! define_runtime_action_bindings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the root-config slot for one runtime action.
|
||||
/// Return the configured slot for one runtime action, including global fallbacks.
|
||||
pub(super) fn configured_binding_for_action(
|
||||
keymap: &TuiKeymap,
|
||||
action: KeymapActionId,
|
||||
) -> Option<&Option<KeybindingsSpec>> {
|
||||
match (action.context.config_name(), action.action) {
|
||||
("composer", "submit") if keymap.composer.submit.is_none() => {
|
||||
Some(&keymap.global.submit)
|
||||
}
|
||||
("composer", "queue") if keymap.composer.queue.is_none() => {
|
||||
Some(&keymap.global.queue)
|
||||
}
|
||||
("composer", "toggle_shortcuts") if keymap.composer.toggle_shortcuts.is_none() => {
|
||||
Some(&keymap.global.toggle_shortcuts)
|
||||
}
|
||||
$(
|
||||
$(
|
||||
($context, stringify!($action)) => {
|
||||
@@ -310,6 +319,7 @@ define_runtime_action_bindings! {
|
||||
start_delete_operator,
|
||||
start_yank_operator,
|
||||
start_change_operator,
|
||||
undo,
|
||||
cancel_operator,
|
||||
],
|
||||
"vim_search" => VimSearch, vim_search, vim_search [forward, backward, next, previous],
|
||||
|
||||
@@ -45,7 +45,6 @@ impl RuntimeKeymap {
|
||||
];
|
||||
for (action, configured, bindings) in &mut actions {
|
||||
let path = format!("tui.keymap.vim_search.{action}");
|
||||
**bindings = resolve_bindings(configured.as_ref(), bindings, &path)?;
|
||||
if configured.is_none() {
|
||||
bindings.retain(|key| {
|
||||
!others.iter().any(|other| {
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."),
|
||||
action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."),
|
||||
action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a motion or text object."),
|
||||
action("vim_normal", "Vim normal", "undo", "Undo the last complete edit."),
|
||||
action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."),
|
||||
action("vim_search", "Vim search", "forward", "Search forward in the active buffer."),
|
||||
action("vim_search", "Vim search", "backward", "Search backward in the active buffer."),
|
||||
@@ -323,6 +324,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator),
|
||||
("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator),
|
||||
("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator),
|
||||
("vim_normal", "undo") => Some(&mut keymap.vim_normal.undo),
|
||||
("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator),
|
||||
("vim_search", "forward") => Some(&mut keymap.vim_search.forward),
|
||||
("vim_search", "backward") => Some(&mut keymap.vim_search.backward),
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
135 actions, 1 customized, 5 unbound.
|
||||
136 actions, 1 customized, 5 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
136 actions, 0 customized, 6 unbound.
|
||||
137 actions, 0 customized, 6 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug
|
||||
|
||||
|
||||
+2
-2
@@ -2,14 +2,14 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (135 selectable)
|
||||
tab: All (136 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (5) (5 selectable)
|
||||
tab: App (14 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (66 selectable)
|
||||
tab: Vim (67 selectable)
|
||||
tab: Navigation (20 selectable)
|
||||
tab: Agents (5 selectable)
|
||||
tab: Approval (8 selectable)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
135 actions, 1 customized, 5 unbound.
|
||||
136 actions, 1 customized, 5 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
135 actions, 0 customized, 5 unbound.
|
||||
136 actions, 0 customized, 5 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (5) App Composer Editor Vim
|
||||
Navigation Agents Approval Debug
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
135 actions, 0 customized, 5 unbound.
|
||||
136 actions, 0 customized, 5 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug
|
||||
|
||||
|
||||
Reference in New Issue
Block a user