mirror of
https://github.com/openai/codex.git
synced 2026-02-20 07:43:47 +00:00
Compare commits
8 Commits
remove/ste
...
dh--vim-mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdb014241 | ||
|
|
ecbcf94088 | ||
|
|
5495bf97d2 | ||
|
|
baea2bc304 | ||
|
|
313d536c83 | ||
|
|
98eb6dc814 | ||
|
|
293c8af02d | ||
|
|
44dfbaef1f |
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -10,4 +10,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc esc to edit previous message "
|
||||
" esc esc to edit previous message 100% context left "
|
||||
|
||||
@@ -10,4 +10,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc again to edit previous message "
|
||||
" esc again to edit previous message 100% context left "
|
||||
|
||||
@@ -10,4 +10,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc esc to edit previous message "
|
||||
" esc esc to edit previous message 100% context left "
|
||||
|
||||
@@ -10,4 +10,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc again to edit previous message "
|
||||
" esc again to edit previous message 100% context left "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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