mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
feat(tui) vim mode
This commit is contained in:
@@ -109,6 +109,12 @@
|
||||
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
|
||||
//! overall state machine, since it affects which transitions are even possible from a given UI
|
||||
//! state.
|
||||
//!
|
||||
//! # Edit Modes
|
||||
//!
|
||||
//! The composer supports Emacs-style input (default) and Vim-style modal input. Vim mode uses the
|
||||
//! textarea's normal/insert states; paste-burst detection is disabled while in Vim normal mode so
|
||||
//! rapid command keystrokes are not buffered as paste.
|
||||
use crate::bottom_pane::footer::mode_indicator_line;
|
||||
use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::key_hint;
|
||||
@@ -766,6 +772,12 @@ impl ChatComposer {
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
|
||||
self.textarea.set_vim_enabled(enabled);
|
||||
self.paste_burst.clear_after_explicit_paste();
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
|
||||
pub(crate) fn current_text_with_pending(&self) -> String {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
@@ -2511,7 +2523,7 @@ impl ChatComposer {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if key_event.code == KeyCode::Esc {
|
||||
if self.is_empty() {
|
||||
if self.is_empty() && !self.textarea.is_vim_insert() {
|
||||
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||||
if next_mode != self.footer_mode {
|
||||
self.footer_mode = next_mode;
|
||||
@@ -2669,7 +2681,7 @@ impl ChatComposer {
|
||||
} = input
|
||||
{
|
||||
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
|
||||
if !has_ctrl_or_alt && !self.disable_paste_burst {
|
||||
if !has_ctrl_or_alt && !self.disable_paste_burst && self.textarea.allows_paste_burst() {
|
||||
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
|
||||
// holding the first char while still allowing burst detection for paste input.
|
||||
if !ch.is_ascii() {
|
||||
|
||||
@@ -287,6 +287,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_vim_enabled(enabled);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ pub(crate) struct TextArea {
|
||||
elements: Vec<TextElement>,
|
||||
next_element_id: u64,
|
||||
kill_buffer: String,
|
||||
vim_enabled: bool,
|
||||
vim_mode: VimMode,
|
||||
vim_operator: Option<VimOperator>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -59,6 +62,18 @@ pub(crate) struct TextAreaState {
|
||||
scroll: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimMode {
|
||||
Normal,
|
||||
Insert,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimOperator {
|
||||
Delete,
|
||||
Yank,
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -69,6 +84,9 @@ impl TextArea {
|
||||
elements: Vec::new(),
|
||||
next_element_id: 1,
|
||||
kill_buffer: String::new(),
|
||||
vim_enabled: false,
|
||||
vim_mode: VimMode::Insert,
|
||||
vim_operator: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +130,24 @@ impl TextArea {
|
||||
self.kill_buffer.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
|
||||
self.vim_enabled = enabled;
|
||||
self.vim_operator = None;
|
||||
self.vim_mode = if enabled {
|
||||
VimMode::Normal
|
||||
} else {
|
||||
VimMode::Insert
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn allows_paste_burst(&self) -> bool {
|
||||
!self.vim_enabled || self.vim_mode == VimMode::Insert
|
||||
}
|
||||
|
||||
pub(crate) fn is_vim_insert(&self) -> bool {
|
||||
self.vim_enabled && self.vim_mode == VimMode::Insert
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
@@ -256,6 +292,14 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: KeyEvent) {
|
||||
if self.vim_enabled {
|
||||
self.handle_vim_input(event);
|
||||
} else {
|
||||
self.handle_emacs_input(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_emacs_input(&mut self, event: KeyEvent) {
|
||||
match event {
|
||||
// Some terminals (or configurations) send Control key chords as
|
||||
// C0 control characters without reporting the CONTROL modifier.
|
||||
@@ -493,6 +537,305 @@ impl TextArea {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vim_input(&mut self, event: KeyEvent) {
|
||||
match self.vim_mode {
|
||||
VimMode::Insert => self.handle_vim_insert(event),
|
||||
VimMode::Normal => self.handle_vim_normal(event),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vim_insert(&mut self, event: KeyEvent) {
|
||||
if matches!(event.code, KeyCode::Esc) {
|
||||
self.vim_mode = VimMode::Normal;
|
||||
self.vim_operator = None;
|
||||
self.preferred_col = None;
|
||||
return;
|
||||
}
|
||||
self.handle_emacs_input(event);
|
||||
}
|
||||
|
||||
fn handle_vim_normal(&mut self, event: KeyEvent) {
|
||||
if let Some(op) = self.vim_operator.take() {
|
||||
if self.handle_vim_operator(op, event) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('i'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
let next = self.next_atomic_boundary(self.cursor_pos);
|
||||
self.set_cursor(next);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('A'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
|
||||
self.set_cursor(self.end_of_current_line());
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('I'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
|
||||
self.set_cursor(self.beginning_of_current_line());
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('o'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
let eol = self.end_of_current_line();
|
||||
let insert_at = if eol < self.text.len() { eol + 1 } else { eol };
|
||||
self.insert_str_at(insert_at, "\n");
|
||||
self.set_cursor(insert_at + 1);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('O'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
|
||||
let bol = self.beginning_of_current_line();
|
||||
self.insert_str_at(bol, "\n");
|
||||
self.set_cursor(bol);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.move_cursor_left(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.move_cursor_right(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.move_cursor_down(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.move_cursor_up(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('w'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.set_cursor(self.beginning_of_next_word()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.set_cursor(self.beginning_of_previous_word()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.set_cursor(self.end_of_next_word()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('0'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.set_cursor(self.beginning_of_current_line()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('$'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
|
||||
self.set_cursor(self.end_of_current_line());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('x'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.delete_forward_kill(1);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('D'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
|
||||
self.kill_to_end_of_line();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('Y'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
|
||||
self.yank_current_line();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.paste_after_cursor(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.vim_operator = Some(VimOperator::Delete);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('y'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.vim_operator = Some(VimOperator::Yank);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.vim_operator = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool {
|
||||
match event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} if op == VimOperator::Delete => {
|
||||
self.delete_current_line();
|
||||
true
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('y'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} if op == VimOperator::Yank => {
|
||||
self.yank_current_line();
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if let Some(motion) = self.vim_motion_for_event(event) {
|
||||
self.apply_vim_operator(op, motion);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn vim_motion_for_event(&self, event: KeyEvent) -> Option<VimMotion> {
|
||||
match event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(VimMotion::Left),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(VimMotion::Right),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(VimMotion::Down),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(VimMotion::Up),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('w'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(VimMotion::WordForward),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(VimMotion::WordBackward),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(VimMotion::WordEnd),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('0'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => Some(VimMotion::LineStart),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('$'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT => {
|
||||
Some(VimMotion::LineEnd)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) {
|
||||
let Some(range) = self.range_for_motion(motion) else {
|
||||
return;
|
||||
};
|
||||
match op {
|
||||
VimOperator::Delete => self.kill_range(range),
|
||||
VimOperator::Yank => self.yank_range(range),
|
||||
}
|
||||
}
|
||||
|
||||
fn range_for_motion(&mut self, motion: VimMotion) -> Option<Range<usize>> {
|
||||
let start = self.cursor_pos;
|
||||
let target = self.target_for_motion(motion);
|
||||
if start == target {
|
||||
return None;
|
||||
}
|
||||
let (range_start, range_end) = if target < start {
|
||||
(target, start)
|
||||
} else {
|
||||
(start, target)
|
||||
};
|
||||
Some(range_start..range_end)
|
||||
}
|
||||
|
||||
fn target_for_motion(&mut self, motion: VimMotion) -> usize {
|
||||
let original_cursor = self.cursor_pos;
|
||||
let original_preferred = self.preferred_col;
|
||||
match motion {
|
||||
VimMotion::Left => self.move_cursor_left(),
|
||||
VimMotion::Right => self.move_cursor_right(),
|
||||
VimMotion::Up => self.move_cursor_up(),
|
||||
VimMotion::Down => self.move_cursor_down(),
|
||||
VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()),
|
||||
VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()),
|
||||
VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()),
|
||||
VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()),
|
||||
VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()),
|
||||
}
|
||||
let target = self.cursor_pos;
|
||||
self.cursor_pos = original_cursor;
|
||||
self.preferred_col = original_preferred;
|
||||
target
|
||||
}
|
||||
|
||||
// ####### Input Functions #######
|
||||
pub fn delete_backward(&mut self, n: usize) {
|
||||
if n == 0 || self.cursor_pos == 0 {
|
||||
@@ -522,6 +865,20 @@ impl TextArea {
|
||||
self.replace_range(self.cursor_pos..target, "");
|
||||
}
|
||||
|
||||
pub fn delete_forward_kill(&mut self, n: usize) {
|
||||
if n == 0 || self.cursor_pos >= self.text.len() {
|
||||
return;
|
||||
}
|
||||
let mut target = self.cursor_pos;
|
||||
for _ in 0..n {
|
||||
target = self.next_atomic_boundary(target);
|
||||
if target >= self.text.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.kill_range(self.cursor_pos..target);
|
||||
}
|
||||
|
||||
pub fn delete_backward_word(&mut self) {
|
||||
let start = self.beginning_of_previous_word();
|
||||
self.kill_range(start..self.cursor_pos);
|
||||
@@ -592,6 +949,45 @@ impl TextArea {
|
||||
self.replace_range_raw(range, "");
|
||||
}
|
||||
|
||||
fn yank_range(&mut self, range: Range<usize>) {
|
||||
let range = self.expand_range_to_element_boundaries(range);
|
||||
if range.start >= range.end {
|
||||
return;
|
||||
}
|
||||
let removed = self.text[range].to_string();
|
||||
if removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.kill_buffer = removed;
|
||||
}
|
||||
|
||||
fn paste_after_cursor(&mut self) {
|
||||
if self.kill_buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
let insert_at = self.next_atomic_boundary(self.cursor_pos);
|
||||
self.set_cursor(insert_at);
|
||||
let text = self.kill_buffer.clone();
|
||||
self.insert_str(&text);
|
||||
}
|
||||
|
||||
fn yank_current_line(&mut self) {
|
||||
let range = self.current_line_range_with_newline();
|
||||
self.yank_range(range);
|
||||
}
|
||||
|
||||
fn delete_current_line(&mut self) {
|
||||
let range = self.current_line_range_with_newline();
|
||||
self.kill_range(range);
|
||||
}
|
||||
|
||||
fn current_line_range_with_newline(&self) -> Range<usize> {
|
||||
let bol = self.beginning_of_current_line();
|
||||
let eol = self.end_of_current_line();
|
||||
let end = if eol < self.text.len() { eol + 1 } else { eol };
|
||||
bol..end
|
||||
}
|
||||
|
||||
/// Move the cursor left by a single grapheme cluster.
|
||||
pub fn move_cursor_left(&mut self) {
|
||||
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
|
||||
@@ -1145,6 +1541,25 @@ impl TextArea {
|
||||
self.adjust_pos_out_of_elements(end, false)
|
||||
}
|
||||
|
||||
pub(crate) fn beginning_of_next_word(&self) -> usize {
|
||||
let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace())
|
||||
else {
|
||||
return self.text.len();
|
||||
};
|
||||
let word_start = self.cursor_pos + first_non_ws;
|
||||
if word_start != self.cursor_pos {
|
||||
return self.adjust_pos_out_of_elements(word_start, true);
|
||||
}
|
||||
let end = self.end_of_next_word();
|
||||
if end >= self.text.len() {
|
||||
return self.text.len();
|
||||
}
|
||||
let Some(next_non_ws) = self.text[end..].find(|c: char| !c.is_whitespace()) else {
|
||||
return self.text.len();
|
||||
};
|
||||
self.adjust_pos_out_of_elements(end + next_non_ws, true)
|
||||
}
|
||||
|
||||
fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize {
|
||||
if let Some(idx) = self.find_element_containing(pos) {
|
||||
let e = &self.elements[idx];
|
||||
@@ -1214,6 +1629,19 @@ impl TextArea {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimMotion {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
WordForward,
|
||||
WordBackward,
|
||||
WordEnd,
|
||||
LineStart,
|
||||
LineEnd,
|
||||
}
|
||||
|
||||
impl WidgetRef for &TextArea {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
@@ -1307,6 +1735,9 @@ impl TextArea {
|
||||
mod tests {
|
||||
use super::*;
|
||||
// crossterm types are intentionally not imported here to avoid unused warnings
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rand::prelude::*;
|
||||
|
||||
@@ -1462,6 +1893,32 @@ mod tests {
|
||||
assert_eq!(t.cursor(), elem_start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_insert_and_escape() {
|
||||
let mut t = TextArea::new();
|
||||
t.set_vim_enabled(true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "h");
|
||||
assert!(!t.is_vim_insert());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_delete_word() {
|
||||
let mut t = ta_with("hello world");
|
||||
t.set_cursor(0);
|
||||
t.set_vim_enabled(true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "world");
|
||||
assert_eq!(t.kill_buffer, "hello ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_backward_word_and_kill_line_variants() {
|
||||
// delete backward word at end removes the whole previous word
|
||||
|
||||
@@ -3324,6 +3324,10 @@ impl ChatWidget {
|
||||
SlashCommand::Permissions => {
|
||||
self.open_permissions_popup();
|
||||
}
|
||||
SlashCommand::Vim => {
|
||||
self.bottom_pane.set_vim_enabled(true);
|
||||
self.add_info_message("Composer input set to vim mode.".to_string(), None);
|
||||
}
|
||||
SlashCommand::ElevateSandbox => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
/// Action returned from feeding a key event into the ComposerInput.
|
||||
pub enum ComposerAction {
|
||||
@@ -56,6 +57,10 @@ impl ComposerInput {
|
||||
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
|
||||
let action = match self.inner.handle_key_event(key).0 {
|
||||
InputResult::Submitted { text, .. } => ComposerAction::Submitted(text),
|
||||
InputResult::Command(cmd) => {
|
||||
self.handle_slash_command(cmd);
|
||||
ComposerAction::None
|
||||
}
|
||||
_ => ComposerAction::None,
|
||||
};
|
||||
self.drain_app_events();
|
||||
@@ -117,6 +122,13 @@ impl ComposerInput {
|
||||
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()
|
||||
}
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: SlashCommand) {
|
||||
match cmd {
|
||||
SlashCommand::Vim => self.inner.set_vim_enabled(true),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_app_events(&mut self) {
|
||||
while self.rx.try_recv().is_ok() {}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum SlashCommand {
|
||||
Approvals,
|
||||
Permissions,
|
||||
#[strum(serialize = "setup-default-sandbox")]
|
||||
Vim,
|
||||
ElevateSandbox,
|
||||
#[strum(serialize = "sandbox-add-read-dir")]
|
||||
SandboxReadRoot,
|
||||
@@ -84,8 +85,9 @@ impl SlashCommand {
|
||||
SlashCommand::Plan => "switch to Plan mode",
|
||||
SlashCommand::Collab => "change collaboration mode (experimental)",
|
||||
SlashCommand::Agent => "switch the active agent thread",
|
||||
SlashCommand::Approvals => "choose what Codex is allowed to do",
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
SlashCommand::Permissions => "choose what Codex is allowed to do",
|
||||
SlashCommand::Vim => "enable vim key bindings for the composer",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::SandboxReadRoot => {
|
||||
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
|
||||
@@ -129,6 +131,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Personality
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Permissions
|
||||
| SlashCommand::Vim
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::SandboxReadRoot
|
||||
| SlashCommand::Experimental
|
||||
|
||||
@@ -38,6 +38,12 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl
|
||||
2. **Paste burst**: transient detection state for non-bracketed paste.
|
||||
- implemented by `PasteBurst`
|
||||
|
||||
`ChatComposer` also coordinates the textarea input mode:
|
||||
|
||||
- **Vim mode**: modal editing (normal/insert). While in Vim normal mode, paste-burst detection
|
||||
is disabled so rapid command keystrokes are never buffered as paste.
|
||||
- Slash command `/vim` enables vim mode in the TUI and the public composer widget.
|
||||
|
||||
### Key event routing
|
||||
|
||||
`ChatComposer::handle_key_event` dispatches based on `active_popup`:
|
||||
|
||||
Reference in New Issue
Block a user