Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1370,6 +1370,15 @@
"description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.",
"type": "boolean"
},
"keymap": {
"allOf": [
{
"$ref": "#/definitions/TuiKeymap"
}
],
"default": "standard",
"description": "Composer input keymap mode.\n\n- `standard` (default): current non-modal editing behavior. - `vim`: vim-style modal editing in the composer input."
},
"notification_method": {
"allOf": [
{
Expand Down Expand Up @@ -1409,6 +1418,13 @@
},
"type": "object"
},
"TuiKeymap": {
"enum": [
"standard",
"vim"
],
"type": "string"
},
"UriBasedFileOpener": {
"oneOf": [
{
Expand Down
48 changes: 48 additions & 0 deletions codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::config::types::ShellEnvironmentPolicy;
use crate::config::types::ShellEnvironmentPolicyToml;
use crate::config::types::SkillsConfig;
use crate::config::types::Tui;
use crate::config::types::TuiKeymap;
use crate::config::types::UriBasedFileOpener;
use crate::config::types::WindowsSandboxModeToml;
use crate::config::types::WindowsToml;
Expand Down Expand Up @@ -281,6 +282,9 @@ pub struct Config {
/// Syntax highlighting theme override (kebab-case name).
pub tui_theme: Option<String>,

/// Composer input keymap mode for the TUI.
pub tui_keymap: TuiKeymap,

/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
Expand Down Expand Up @@ -2124,6 +2128,7 @@ impl Config {
.unwrap_or_default(),
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
tui_keymap: cfg.tui.as_ref().map(|t| t.keymap).unwrap_or_default(),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
Expand Down Expand Up @@ -2546,6 +2551,44 @@ theme = "dracula"
assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None);
}

#[test]
fn tui_keymap_defaults_to_standard() {
let cfg = r#"
[tui]
"#;
let parsed =
toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().map(|t| t.keymap),
Some(TuiKeymap::Standard),
);
}

#[test]
fn tui_keymap_deserializes_vim() {
let cfg = r#"
[tui]
keymap = "vim"
"#;
let parsed =
toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(parsed.tui.as_ref().map(|t| t.keymap), Some(TuiKeymap::Vim));
}

#[test]
fn tui_keymap_invalid_value_errors() {
let cfg = r#"
[tui]
keymap = "bad"
"#;
let err = toml::from_str::<ConfigToml>(cfg).expect_err("invalid keymap should fail");
let msg = err.to_string();
assert!(
msg.contains("keymap"),
"expected error mentioning keymap, got: {msg}"
);
}

#[test]
fn tui_config_missing_notifications_field_defaults_to_enabled() {
let cfg = r#"
Expand All @@ -2566,6 +2609,7 @@ theme = "dracula"
alternate_screen: AltScreenMode::Auto,
status_line: None,
theme: None,
keymap: TuiKeymap::Standard,
}
);
}
Expand Down Expand Up @@ -4676,6 +4720,7 @@ model_verbosity = "high"
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_theme: None,
tui_keymap: TuiKeymap::Standard,
otel: OtelConfig::default(),
},
o3_profile_config
Expand Down Expand Up @@ -4799,6 +4844,7 @@ model_verbosity = "high"
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_theme: None,
tui_keymap: TuiKeymap::Standard,
otel: OtelConfig::default(),
};

Expand Down Expand Up @@ -4920,6 +4966,7 @@ model_verbosity = "high"
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_theme: None,
tui_keymap: TuiKeymap::Standard,
otel: OtelConfig::default(),
};

Expand Down Expand Up @@ -5027,6 +5074,7 @@ model_verbosity = "high"
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_theme: None,
tui_keymap: TuiKeymap::Standard,
otel: OtelConfig::default(),
};

Expand Down
15 changes: 15 additions & 0 deletions codex-rs/core/src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,14 @@ impl fmt::Display for NotificationMethod {
}
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum TuiKeymap {
#[default]
Standard,
Vim,
}

/// Collection of settings that are specific to the TUI.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
Expand Down Expand Up @@ -688,6 +696,13 @@ pub struct Tui {
/// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.
#[serde(default)]
pub theme: Option<String>,

/// Composer input keymap mode.
///
/// - `standard` (default): current non-modal editing behavior.
/// - `vim`: vim-style modal editing in the composer input.
#[serde(default)]
pub keymap: TuiKeymap,
}

const fn default_true() -> bool {
Expand Down
31 changes: 26 additions & 5 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
//! overall state machine, since it affects which transitions are even possible from a given UI
//! state.
//!
//! # Edit Modes
//!
//! The composer supports Emacs-style input (default) and Vim-style modal input. Vim mode uses the
//! textarea's normal/insert states; paste-burst detection is disabled while in Vim normal mode so
//! rapid command keystrokes are not buffered as paste.
use crate::bottom_pane::footer::mode_indicator_line;
use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow;
use crate::key_hint;
Expand Down Expand Up @@ -766,6 +772,12 @@ impl ChatComposer {
self.sync_popups();
}

pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.textarea.set_vim_enabled(enabled);
self.paste_burst.clear_after_explicit_paste();
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}

pub(crate) fn current_text_with_pending(&self) -> String {
let mut text = self.textarea.text().to_string();
for (placeholder, actual) in &self.pending_pastes {
Expand Down Expand Up @@ -2511,7 +2523,7 @@ impl ChatComposer {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
if self.is_empty() {
if self.is_empty() && !self.textarea.is_vim_insert() {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
Expand Down Expand Up @@ -2669,7 +2681,7 @@ impl ChatComposer {
} = input
{
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if !has_ctrl_or_alt && !self.disable_paste_burst {
if !has_ctrl_or_alt && !self.disable_paste_burst && self.textarea.allows_paste_burst() {
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
// holding the first char while still allowing burst detection for paste input.
if !ch.is_ascii() {
Expand Down Expand Up @@ -3678,6 +3690,13 @@ impl ChatComposer {
show_shortcuts_hint,
show_queue_hint,
);
// Render vim mode indicator only in the default footer path so it does not
// obscure flash/status/override content.
if let Some(label) = self.textarea.vim_mode_label() {
let vim_line = Line::from(format!("-- {label} --")).dim();
let area = inset_footer_hint_area(hint_rect);
vim_line.render(area, buf);
}
}

if show_right && let Some(line) = &right_line {
Expand All @@ -3693,10 +3712,12 @@ impl ChatComposer {
.render_ref(remote_images_rect, buf);
}
if !textarea_rect.is_empty() {
let prompt = if self.input_enabled {
"›".bold()
} else {
let prompt = if !self.input_enabled {
"›".dim()
} else if self.textarea.vim_mode_label().is_some() && !self.textarea.is_vim_insert() {
"·".bold()
} else {
"›".bold()
};
buf.set_span(
textarea_rect.x - LIVE_PREFIX_COLS,
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ impl BottomPane {
self.request_redraw();
}

pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
self.composer.set_vim_enabled(enabled);
self.request_redraw();
}

pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
self.status.as_ref()
}
Expand Down
Loading