Compare commits

...

8 Commits

Author SHA1 Message Date
Dylan Hurd
0fdb014241 clean up snapshot tests 2026-02-16 14:08:46 -08:00
Dylan Hurd
ecbcf94088 cleanup snapshots 2026-02-15 18:19:57 -08:00
Dylan Hurd
5495bf97d2 clippy 2026-02-15 18:19:22 -08:00
Dylan Hurd
baea2bc304 Add experimental label to command 2026-02-15 12:17:28 -08:00
Dylan Hurd
313d536c83 cleanup 2026-02-15 11:21:31 -08:00
Dylan Hurd
98eb6dc814 working 2026-02-15 11:21:31 -08:00
Dylan Hurd
293c8af02d iteration 2026-02-15 11:21:31 -08:00
Dylan Hurd
44dfbaef1f feat(tui) vim mode 2026-02-15 11:21:31 -08:00
14 changed files with 835 additions and 140 deletions

View File

@@ -109,7 +109,13 @@
//! 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.
use crate::bottom_pane::footer::mode_indicator_line;
//!
//! # 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 as collaboration_mode_indicator_line;
use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow;
use crate::key_hint;
use crate::key_hint::KeyBinding;
@@ -766,6 +772,60 @@ 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 toggle_vim_enabled(&mut self) -> bool {
let enabled = !self.textarea.is_vim_enabled();
self.set_vim_enabled(enabled);
enabled
}
pub(crate) fn is_vim_insert(&self) -> bool {
self.textarea.is_vim_insert()
}
fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
self.textarea.vim_mode_label().map(|label| match label {
"Normal" => "Vim: Normal".magenta(),
"Insert" => "Vim: Insert".green(),
_ => unreachable!(),
})
}
fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option<Line<'static>> {
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(vim_mode) = self.vim_mode_indicator_span() {
spans.push(vim_mode);
}
if let Some(collab) =
collaboration_mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint)
{
if !spans.is_empty() {
spans.push(" | ".dim());
}
spans.extend(collab.spans);
}
if spans.is_empty() {
None
} else {
Some(Line::from(spans))
}
}
fn right_footer_line_with_context(&self) -> Line<'static> {
let mut line =
context_window_line(self.context_window_percent, self.context_window_used_tokens);
if let Some(vim_mode) = self.vim_mode_indicator_span() {
line.spans.push(" | ".dim());
line.spans.push(vim_mode);
}
line
}
pub(crate) fn current_text_with_pending(&self) -> String {
let mut text = self.textarea.text().to_string();
for (placeholder, actual) in &self.pending_pastes {
@@ -2510,8 +2570,11 @@ impl ChatComposer {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc && self.textarea.is_vim_insert() {
return self.handle_input_basic(key_event);
}
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 +2732,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() {
@@ -2852,8 +2915,6 @@ impl ChatComposer {
steer_enabled: self.steer_enabled,
collaboration_modes_enabled: self.collaboration_modes_enabled,
is_wsl,
context_window_percent: self.context_window_percent,
context_window_used_tokens: self.context_window_used_tokens,
status_line_value: self.status_line_value.clone(),
status_line_enabled: self.status_line_enabled,
}
@@ -3557,9 +3618,8 @@ impl ChatComposer {
)
};
let right_line = if status_line_active {
let full =
mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint);
let compact = mode_indicator_line(self.collaboration_mode_indicator, false);
let full = self.mode_indicator_line(show_cycle_hint);
let compact = self.mode_indicator_line(false);
let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0);
if can_show_left_with_context(hint_rect, left_width, full_width) {
full
@@ -3567,10 +3627,7 @@ impl ChatComposer {
compact
}
} else {
Some(context_window_line(
footer_props.context_window_percent,
footer_props.context_window_used_tokens,
))
Some(self.right_footer_line_with_context())
};
let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0);
if status_line_active
@@ -3612,9 +3669,7 @@ impl ChatComposer {
};
let show_right = if matches!(
footer_props.mode,
FooterMode::EscHint
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay
) {
false
} else {
@@ -4364,6 +4419,76 @@ mod tests {
}
}
#[test]
fn vim_mode_stays_enabled_after_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
composer.set_vim_enabled(true);
assert!(composer.textarea.is_vim_enabled());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
composer.set_text_content("h".to_string(), Vec::new(), Vec::new());
let (result, _) = composer.handle_submission(false);
assert!(composer.textarea.is_vim_enabled());
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Insert".green())
);
assert!(composer.is_empty());
match result {
InputResult::Submitted { text, .. } => assert_eq!(text, "h"),
_ => panic!("expected Submitted"),
}
}
#[test]
fn esc_switches_vim_insert_to_normal() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_vim_enabled(true);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Insert".green())
);
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(
composer.vim_mode_indicator_span(),
Some("Vim: Normal".magenta())
);
}
#[test]
fn clear_for_ctrl_c_preserves_image_draft_state() {
let (tx, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -66,8 +66,6 @@ pub(crate) struct FooterProps {
///
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
pub(crate) quit_shortcut_key: KeyBinding,
pub(crate) context_window_percent: Option<i64>,
pub(crate) context_window_used_tokens: Option<i64>,
pub(crate) status_line_value: Option<Line<'static>>,
pub(crate) status_line_enabled: bool,
}
@@ -597,7 +595,11 @@ fn footer_from_props_lines(
};
shortcut_overlay_lines(state)
}
FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)],
FooterMode::EscHint => vec![esc_hint_left_line(
props.esc_backtrack_hint,
collaboration_mode_indicator,
show_cycle_hint,
)],
FooterMode::ComposerHasDraft => {
let state = LeftSideState {
hint: if show_queue_hint {
@@ -678,6 +680,19 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
}
}
fn esc_hint_left_line(
esc_backtrack_hint: bool,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
show_cycle_hint: bool,
) -> Line<'static> {
let mut line = esc_hint_line(esc_backtrack_hint);
if let Some(collaboration_mode_indicator) = collaboration_mode_indicator {
line.push_span(" · ".dim());
line.push_span(collaboration_mode_indicator.styled_span(show_cycle_hint));
}
line
}
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut commands = Line::from("");
let mut shell_commands = Line::from("");
@@ -999,7 +1014,7 @@ mod tests {
use ratatui::backend::TestBackend;
fn snapshot_footer(name: &str, props: FooterProps) {
snapshot_footer_with_mode_indicator(name, 80, &props, None);
snapshot_footer_with_mode_indicator(name, 80, &props, None, None);
}
fn draw_footer_frame<B: Backend>(
@@ -1007,6 +1022,7 @@ mod tests {
height: u16,
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
right_context_override: Option<Line<'static>>,
) {
terminal
.draw(|f| {
@@ -1069,10 +1085,9 @@ mod tests {
compact
}
} else {
Some(context_window_line(
props.context_window_percent,
props.context_window_used_tokens,
))
right_context_override
.clone()
.or_else(|| Some(context_window_line(None, None)))
};
let right_width = right_line
.as_ref()
@@ -1145,9 +1160,7 @@ mod tests {
let show_context = can_show_left_and_context
&& !matches!(
props.mode,
FooterMode::EscHint
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay
);
if show_context && let Some(line) = &right_line {
render_context_right(area, f.buffer_mut(), line);
@@ -1162,10 +1175,17 @@ mod tests {
width: u16,
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
right_context_override: Option<Line<'static>>,
) {
let height = footer_height(props).max(1);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator);
draw_footer_frame(
&mut terminal,
height,
props,
collaboration_mode_indicator,
right_context_override,
);
assert_snapshot!(name, terminal.backend());
}
@@ -1176,7 +1196,13 @@ mod tests {
) -> String {
let height = footer_height(props).max(1);
let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal");
draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator);
draw_footer_frame(
&mut terminal,
height,
props,
collaboration_mode_indicator,
None,
);
terminal.backend().vt100().screen().contents()
}
@@ -1193,8 +1219,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
@@ -1211,8 +1235,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
@@ -1229,8 +1251,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
@@ -1247,8 +1267,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
@@ -1265,8 +1283,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
@@ -1283,8 +1299,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
@@ -1301,47 +1315,49 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
);
snapshot_footer(
let context_running = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
status_line_value: None,
status_line_enabled: false,
};
snapshot_footer_with_mode_indicator(
"footer_shortcuts_context_running",
FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(72),
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
80,
&context_running,
None,
Some(context_window_line(Some(72), None)),
);
snapshot_footer(
let context_tokens_used = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
status_line_value: None,
status_line_enabled: false,
};
snapshot_footer_with_mode_indicator(
"footer_context_tokens_used",
FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: Some(123_456),
status_line_value: None,
status_line_enabled: false,
},
80,
&context_tokens_used,
None,
Some(context_window_line(None, Some(123_456))),
);
snapshot_footer(
@@ -1355,8 +1371,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
@@ -1373,8 +1387,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
},
@@ -1389,8 +1401,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
};
@@ -1400,6 +1410,7 @@ mod tests {
120,
&props,
Some(CollaborationModeIndicator::Plan),
None,
);
snapshot_footer_with_mode_indicator(
@@ -1407,6 +1418,7 @@ mod tests {
50,
&props,
Some(CollaborationModeIndicator::Plan),
None,
);
let props = FooterProps {
@@ -1418,8 +1430,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
};
@@ -1429,6 +1439,7 @@ mod tests {
120,
&props,
Some(CollaborationModeIndicator::Plan),
None,
);
let props = FooterProps {
@@ -1440,8 +1451,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
};
@@ -1457,8 +1466,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: None, // command timed out / empty
status_line_enabled: true,
};
@@ -1468,50 +1475,6 @@ mod tests {
120,
&props,
Some(CollaborationModeIndicator::Plan),
);
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
};
snapshot_footer_with_mode_indicator(
"footer_status_line_disabled_context_right",
120,
&props,
Some(CollaborationModeIndicator::Plan),
);
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: true,
};
// has status line and no collaboration mode
snapshot_footer_with_mode_indicator(
"footer_status_line_enabled_no_mode_right",
120,
&props,
None,
);
@@ -1524,8 +1487,49 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
};
snapshot_footer_with_mode_indicator(
"footer_status_line_disabled_context_right",
120,
&props,
Some(CollaborationModeIndicator::Plan),
Some(context_window_line(Some(50), None)),
);
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
status_line_value: None,
status_line_enabled: true,
};
// has status line and no collaboration mode
snapshot_footer_with_mode_indicator(
"footer_status_line_enabled_no_mode_right",
120,
&props,
None,
None,
);
let props = FooterProps {
mode: FooterMode::ComposerEmpty,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
status_line_value: Some(Line::from(
"Status line content that should truncate before the mode indicator".to_string(),
)),
@@ -1537,6 +1541,7 @@ mod tests {
40,
&props,
Some(CollaborationModeIndicator::Plan),
None,
);
}
@@ -1551,8 +1556,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: Some(Line::from(
"Status line content that is definitely too long to fit alongside the mode label"
.to_string(),
@@ -1577,6 +1580,39 @@ mod tests {
);
}
#[test]
fn footer_esc_hint_keeps_right_context() {
let props = FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: true,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
status_line_value: None,
status_line_enabled: false,
};
let screen = render_footer_with_mode_indicator(120, &props, None);
let expected_context = context_window_line(None, None);
let expected_context_text = expected_context
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(
screen.contains("again to edit previous message"),
"left esc hint should be visible"
);
assert!(
screen.contains(&expected_context_text),
"right-side context should still be visible during Esc hint"
);
}
#[test]
fn paste_image_shortcut_prefers_ctrl_alt_v_under_wsl() {
let descriptor = SHORTCUTS

View File

@@ -287,6 +287,12 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn toggle_vim_enabled(&mut self) -> bool {
let enabled = self.composer.toggle_vim_enabled();
self.request_redraw();
enabled
}
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
self.status.as_ref()
}
@@ -745,7 +751,10 @@ impl BottomPane {
/// overlays or popups and not running a task. This is the safe context to
/// use Esc-Esc for backtracking from the main view.
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
!self.is_task_running
&& self.view_stack.is_empty()
&& !self.composer.popup_active()
&& !self.composer.is_vim_insert()
}
/// Return true when no popups or modal views are active, regardless of task state.
@@ -1492,6 +1501,31 @@ mod tests {
);
}
#[test]
fn normal_backtrack_mode_excludes_vim_insert() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.toggle_vim_enabled();
assert!(pane.is_normal_backtrack_mode());
pane.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert!(!pane.is_normal_backtrack_mode());
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(pane.is_normal_backtrack_mode());
}
#[test]
fn esc_routes_to_handle_key_event_when_requested() {
#[derive(Default)]

View File

@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" esc esc to edit previous message "
" esc esc to edit previous message 100% context left "

View File

@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" esc again to edit previous message "
" esc again to edit previous message 100% context left "

View File

@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" esc esc to edit previous message "
" esc esc to edit previous message 100% context left "

View File

@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" esc again to edit previous message "
" esc again to edit previous message 100% context left "

View File

@@ -2,4 +2,4 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" esc esc to edit previous message "
" esc esc to edit previous message 100% context left "

View File

@@ -2,4 +2,4 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" esc again to edit previous message "
" esc again to edit previous message 100% context left "

View File

@@ -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,38 @@ 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 is_vim_enabled(&self) -> bool {
self.vim_enabled
}
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(crate) fn vim_mode_label(&self) -> Option<&'static str> {
if !self.vim_enabled {
return None;
}
Some(match self.vim_mode {
VimMode::Normal => "Normal",
VimMode::Insert => "Insert",
})
}
pub fn text(&self) -> &str {
&self.text
}
@@ -256,6 +306,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 +551,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()
&& 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 +879,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 +963,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 +1555,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 +1643,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 +1749,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 +1907,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

View File

@@ -3324,6 +3324,15 @@ impl ChatWidget {
SlashCommand::Permissions => {
self.open_permissions_popup();
}
SlashCommand::Vim => {
let enabled = self.bottom_pane.toggle_vim_enabled();
let message = if enabled {
"Vim mode enabled."
} else {
"Vim mode disabled."
};
self.add_info_message(message.to_string(), None);
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
{

View File

@@ -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,12 @@ impl ComposerInput {
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay()
}
fn handle_slash_command(&mut self, cmd: SlashCommand) {
if cmd == SlashCommand::Vim {
self.inner.toggle_vim_enabled();
}
}
fn drain_app_events(&mut self) {
while self.rx.try_recv().is_ok() {}
}

View File

@@ -15,6 +15,7 @@ pub enum SlashCommand {
Model,
Approvals,
Permissions,
Vim,
#[strum(serialize = "setup-default-sandbox")]
ElevateSandbox,
#[strum(serialize = "sandbox-add-read-dir")]
@@ -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 => "Experimental: toggle 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

View File

@@ -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`: