mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
iteration
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"
|
||||
|
||||
Reference in New Issue
Block a user