iteration

This commit is contained in:
Dylan Hurd
2026-02-15 11:21:31 -08:00
parent 44dfbaef1f
commit 293c8af02d
7 changed files with 208 additions and 22 deletions
+122 -11
View File
@@ -115,7 +115,7 @@
//! 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::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;
@@ -778,6 +778,50 @@ impl ChatComposer {
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
}
fn vim_mode_indicator_span(&self) -> Option<Span<'static>> {
self.textarea.vim_mode_label().map(|label| match label {
"Normal" => "Vim: Normal".yellow(),
"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().into());
}
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().into());
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 {
@@ -2522,6 +2566,9 @@ 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() && !self.textarea.is_vim_insert() {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
@@ -3569,9 +3616,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
@@ -3579,10 +3625,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
@@ -3624,9 +3667,7 @@ impl ChatComposer {
};
let show_right = if matches!(
footer_props.mode,
FooterMode::EscHint
| FooterMode::QuitShortcutReminder
| FooterMode::ShortcutOverlay
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay
) {
false
} else {
@@ -4376,6 +4417,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".yellow())
);
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".yellow())
);
}
#[test]
fn clear_for_ctrl_c_preserves_image_draft_state() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
+57 -4
View File
@@ -597,7 +597,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 +682,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("");
@@ -1145,9 +1162,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);
@@ -1577,6 +1592,44 @@ 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')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
};
let screen = render_footer_with_mode_indicator(120, &props, None);
let expected_context = context_window_line(
props.context_window_percent,
props.context_window_used_tokens,
);
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
+3 -2
View File
@@ -287,9 +287,10 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.composer.set_vim_enabled(enabled);
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> {
+14
View File
@@ -140,6 +140,10 @@ impl TextArea {
};
}
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
}
@@ -148,6 +152,16 @@ impl TextArea {
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
}
+7 -2
View File
@@ -3325,8 +3325,13 @@ impl ChatWidget {
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);
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")]
@@ -124,7 +124,9 @@ impl ComposerInput {
fn handle_slash_command(&mut self, cmd: SlashCommand) {
match cmd {
SlashCommand::Vim => self.inner.set_vim_enabled(true),
SlashCommand::Vim => {
self.inner.toggle_vim_enabled();
}
_ => {}
}
}
+2 -2
View File
@@ -15,8 +15,8 @@ pub enum SlashCommand {
Model,
Approvals,
Permissions,
#[strum(serialize = "setup-default-sandbox")]
Vim,
#[strum(serialize = "setup-default-sandbox")]
ElevateSandbox,
#[strum(serialize = "sandbox-add-read-dir")]
SandboxReadRoot,
@@ -87,7 +87,7 @@ impl SlashCommand {
SlashCommand::Agent => "switch the active agent thread",
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::Vim => "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>"