feat(tui): add configurable Vim keymap and startup default

Expose Vim bindings through the TUI keymap config and add a startup
toggle so Vim mode can be enabled by default.

Wire runtime keymap handling for Vim actions, add
`tui.vim_mode_default` to config parsing/runtime config, apply the
default in ChatWidget startup paths, and update docs/default
keymap/schema accordingly.
This commit is contained in:
Felipe Coury
2026-04-23 10:35:17 -03:00
committed by Felipe Coury
parent d49e77a7c2
commit cb3e5baa73
17 changed files with 1450 additions and 243 deletions
+52
View File
@@ -108,6 +108,8 @@ pub struct TuiGlobalKeymap {
pub queue: Option<KeybindingsSpec>,
/// Toggle the composer shortcut overlay.
pub toggle_shortcuts: Option<KeybindingsSpec>,
/// Toggle Vim mode for the composer input.
pub toggle_vim_mode: Option<KeybindingsSpec>,
}
/// Chat context keybindings. These override corresponding `global` actions.
@@ -170,6 +172,52 @@ pub struct TuiEditorKeymap {
pub yank: Option<KeybindingsSpec>,
}
/// Vim normal-mode keybindings for modal editing inside text areas.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimNormalKeymap {
pub enter_insert: Option<KeybindingsSpec>,
pub append_after_cursor: Option<KeybindingsSpec>,
pub append_line_end: Option<KeybindingsSpec>,
pub insert_line_start: Option<KeybindingsSpec>,
pub open_line_below: Option<KeybindingsSpec>,
pub open_line_above: Option<KeybindingsSpec>,
pub move_left: Option<KeybindingsSpec>,
pub move_right: Option<KeybindingsSpec>,
pub move_up: Option<KeybindingsSpec>,
pub move_down: Option<KeybindingsSpec>,
pub move_word_forward: Option<KeybindingsSpec>,
pub move_word_backward: Option<KeybindingsSpec>,
pub move_word_end: Option<KeybindingsSpec>,
pub move_line_start: Option<KeybindingsSpec>,
pub move_line_end: Option<KeybindingsSpec>,
pub delete_char: Option<KeybindingsSpec>,
pub delete_to_line_end: Option<KeybindingsSpec>,
pub yank_line: Option<KeybindingsSpec>,
pub paste_after: Option<KeybindingsSpec>,
pub start_delete_operator: Option<KeybindingsSpec>,
pub start_yank_operator: Option<KeybindingsSpec>,
pub cancel_operator: Option<KeybindingsSpec>,
}
/// Vim operator-pending keybindings for modal editing inside text areas.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiVimOperatorKeymap {
pub delete_line: Option<KeybindingsSpec>,
pub yank_line: Option<KeybindingsSpec>,
pub motion_left: Option<KeybindingsSpec>,
pub motion_right: Option<KeybindingsSpec>,
pub motion_up: Option<KeybindingsSpec>,
pub motion_down: Option<KeybindingsSpec>,
pub motion_word_forward: Option<KeybindingsSpec>,
pub motion_word_backward: Option<KeybindingsSpec>,
pub motion_word_end: Option<KeybindingsSpec>,
pub motion_line_start: Option<KeybindingsSpec>,
pub motion_line_end: Option<KeybindingsSpec>,
pub cancel: Option<KeybindingsSpec>,
}
/// Pager context keybindings for transcript and static overlays.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -278,6 +326,10 @@ pub struct TuiKeymap {
#[serde(default)]
pub editor: TuiEditorKeymap,
#[serde(default)]
pub vim_normal: TuiVimNormalKeymap,
#[serde(default)]
pub vim_operator: TuiVimOperatorKeymap,
#[serde(default)]
pub pager: TuiPagerKeymap,
#[serde(default)]
pub list: TuiListKeymap,
+7
View File
@@ -38,6 +38,8 @@ pub use crate::tui_keymap::TuiKeymapPreset;
pub use crate::tui_keymap::TuiListKeymap;
pub use crate::tui_keymap::TuiOnboardingKeymap;
pub use crate::tui_keymap::TuiPagerKeymap;
pub use crate::tui_keymap::TuiVimNormalKeymap;
pub use crate::tui_keymap::TuiVimOperatorKeymap;
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 16;
@@ -562,6 +564,11 @@ pub struct Tui {
#[serde(default = "default_true")]
pub show_tooltips: bool,
/// Start the composer in Vim mode (`Normal`) by default.
/// Defaults to `false`.
#[serde(default)]
pub vim_mode_default: bool,
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
+224 -3
View File
@@ -2236,7 +2236,8 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_shortcuts": null
"toggle_shortcuts": null,
"toggle_vim_mode": null
},
"list": {
"accept": null,
@@ -2270,7 +2271,45 @@
"scroll_down": null,
"scroll_up": null
},
"preset": "latest"
"preset": "latest",
"vim_normal": {
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
"move_line_start": null,
"move_right": null,
"move_up": null,
"move_word_backward": null,
"move_word_end": null,
"move_word_forward": null,
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"start_delete_operator": null,
"start_yank_operator": null,
"yank_line": null
},
"vim_operator": {
"cancel": null,
"delete_line": null,
"motion_down": null,
"motion_left": null,
"motion_line_end": null,
"motion_line_start": null,
"motion_right": null,
"motion_up": null,
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"yank_line": null
}
},
"description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings."
},
@@ -2335,6 +2374,11 @@
"default": null,
"description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.",
"type": "string"
},
"vim_mode_default": {
"default": false,
"description": "Start the composer in Vim mode (`Normal`) by default. Defaults to `false`.",
"type": "boolean"
}
},
"type": "object"
@@ -2642,6 +2686,14 @@
}
],
"description": "Toggle the composer shortcut overlay."
},
"toggle_vim_mode": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Toggle Vim mode for the composer input."
}
},
"type": "object"
@@ -2726,7 +2778,8 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_shortcuts": null
"toggle_shortcuts": null,
"toggle_vim_mode": null
}
},
"list": {
@@ -2789,6 +2842,58 @@
}
],
"default": "latest"
},
"vim_normal": {
"allOf": [
{
"$ref": "#/definitions/TuiVimNormalKeymap"
}
],
"default": {
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
"move_line_start": null,
"move_right": null,
"move_up": null,
"move_word_backward": null,
"move_word_end": null,
"move_word_forward": null,
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"start_delete_operator": null,
"start_yank_operator": null,
"yank_line": null
}
},
"vim_operator": {
"allOf": [
{
"$ref": "#/definitions/TuiVimOperatorKeymap"
}
],
"default": {
"cancel": null,
"delete_line": null,
"motion_down": null,
"motion_left": null,
"motion_line_end": null,
"motion_line_start": null,
"motion_right": null,
"motion_up": null,
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"yank_line": null
}
}
},
"type": "object"
@@ -3041,6 +3146,122 @@
},
"type": "object"
},
"TuiVimNormalKeymap": {
"additionalProperties": false,
"description": "Vim normal-mode keybindings for modal editing inside text areas.",
"properties": {
"append_after_cursor": {
"$ref": "#/definitions/KeybindingsSpec"
},
"append_line_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"cancel_operator": {
"$ref": "#/definitions/KeybindingsSpec"
},
"delete_char": {
"$ref": "#/definitions/KeybindingsSpec"
},
"delete_to_line_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"enter_insert": {
"$ref": "#/definitions/KeybindingsSpec"
},
"insert_line_start": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_down": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_left": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_line_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_line_start": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_right": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_up": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_word_backward": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_word_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"move_word_forward": {
"$ref": "#/definitions/KeybindingsSpec"
},
"open_line_above": {
"$ref": "#/definitions/KeybindingsSpec"
},
"open_line_below": {
"$ref": "#/definitions/KeybindingsSpec"
},
"paste_after": {
"$ref": "#/definitions/KeybindingsSpec"
},
"start_delete_operator": {
"$ref": "#/definitions/KeybindingsSpec"
},
"start_yank_operator": {
"$ref": "#/definitions/KeybindingsSpec"
},
"yank_line": {
"$ref": "#/definitions/KeybindingsSpec"
}
},
"type": "object"
},
"TuiVimOperatorKeymap": {
"additionalProperties": false,
"description": "Vim operator-pending keybindings for modal editing inside text areas.",
"properties": {
"cancel": {
"$ref": "#/definitions/KeybindingsSpec"
},
"delete_line": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_down": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_left": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_line_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_line_start": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_right": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_up": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_word_backward": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_word_end": {
"$ref": "#/definitions/KeybindingsSpec"
},
"motion_word_forward": {
"$ref": "#/definitions/KeybindingsSpec"
},
"yank_line": {
"$ref": "#/definitions/KeybindingsSpec"
}
},
"type": "object"
},
"UriBasedFileOpener": {
"oneOf": [
{
+34
View File
@@ -543,6 +543,35 @@ async fn runtime_config_defaults_model_availability_nux() {
);
}
#[test]
fn test_tui_vim_mode_default_defaults_to_false() {
let toml = r#"
[tui]
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
assert!(
!parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn test_tui_vim_mode_default_true() {
let toml = r#"
[tui]
vim_mode_default = true
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize vim_mode_default=true");
assert!(
parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
@@ -1425,6 +1454,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
notification_settings: TuiNotificationSettings::default(),
animations: true,
show_tooltips: true,
vim_mode_default: false,
alternate_screen: AltScreenMode::Auto,
status_line: None,
terminal_title: None,
@@ -5269,6 +5299,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
@@ -5465,6 +5496,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
@@ -5615,6 +5647,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
analytics_enabled: Some(false),
feedback_enabled: true,
@@ -5750,6 +5783,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
analytics_enabled: Some(true),
feedback_enabled: true,
+8
View File
@@ -356,6 +356,9 @@ pub struct Config {
/// Persisted startup availability NUX state for model tooltips.
pub model_availability_nux: ModelAvailabilityNuxConfig,
/// Start the composer in Vim mode (`Normal`) by default.
pub tui_vim_mode_default: bool,
/// Start the TUI in the specified collaboration mode (plan/default).
/// Controls whether the TUI uses the terminal's alternate screen buffer.
@@ -2470,6 +2473,11 @@ impl Config {
.as_ref()
.map(|t| t.model_availability_nux.clone())
.unwrap_or_default(),
tui_vim_mode_default: cfg
.tui
.as_ref()
.map(|t| t.vim_mode_default)
.unwrap_or(false),
tui_alternate_screen: cfg
.tui
.as_ref()
+5
View File
@@ -148,6 +148,11 @@ impl App {
return;
}
if self.keymap.app.toggle_vim_mode.is_pressed(key_event) {
self.chat_widget.toggle_vim_mode_and_notify();
return;
}
if self.keymap.chat.edit_previous_message.is_pressed(key_event) {
// Esc primes/advances backtracking only in normal (not working) mode
// with the composer focused and empty. In any other state, forward
+63 -4
View File
@@ -635,7 +635,7 @@ impl ChatComposer {
self.queue_keys = keymap.composer.queue.clone();
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
self.editor_keymap = keymap.editor.clone();
self.textarea.set_keymap_bindings(&self.editor_keymap);
self.textarea.set_keymap_bindings(keymap);
self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor);
self.footer_edit_previous_key = primary_binding(&keymap.chat.edit_previous_message);
self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript);
@@ -956,6 +956,65 @@ 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()
}
#[cfg(test)]
pub(crate) fn is_vim_enabled(&self) -> bool {
self.textarea.is_vim_enabled()
}
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.current_text();
for (placeholder, actual) in &self.pending_pastes {
@@ -1641,7 +1700,7 @@ impl ChatComposer {
if self.disable_paste_burst {
// When burst detection is disabled, treat IME/non-ASCII input as normal typing.
// In particular, do not retro-capture or buffer already-inserted prefix text.
self.textarea.input_with_keymap(input, &self.editor_keymap);
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
@@ -1698,7 +1757,7 @@ impl ChatComposer {
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.textarea.input_with_keymap(input, &self.editor_keymap);
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
@@ -3114,7 +3173,7 @@ impl ChatComposer {
return (InputResult::None, true);
}
self.textarea.input_with_keymap(input, &self.editor_keymap);
self.textarea.input(input);
self.sync_bash_mode_from_text();
if let Some(elements_before) = elements_before {
+14
View File
@@ -380,6 +380,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()
}
@@ -947,6 +952,15 @@ impl BottomPane {
self.composer.is_empty()
}
pub(crate) fn composer_is_vim_insert(&self) -> bool {
self.composer.is_vim_insert()
}
#[cfg(test)]
pub(crate) fn composer_is_vim_enabled(&self) -> bool {
self.composer.is_vim_enabled()
}
pub(crate) fn is_task_running(&self) -> bool {
self.is_task_running
}
+164 -223
View File
@@ -14,6 +14,8 @@ use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
use crate::keymap::EditorKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::VimOperatorKeymap;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement as UserTextElement;
use crossterm::event::KeyCode;
@@ -99,6 +101,8 @@ pub(crate) struct TextArea {
vim_mode: VimMode,
vim_operator: Option<VimOperator>,
editor_keymap: EditorKeymap,
vim_normal_keymap: VimNormalKeymap,
vim_operator_keymap: VimOperatorKeymap,
}
#[derive(Debug, Clone)]
@@ -140,6 +144,7 @@ enum VimMotion {
impl TextArea {
pub fn new() -> Self {
let defaults = RuntimeKeymap::defaults();
Self {
text: String::new(),
cursor_pos: 0,
@@ -151,12 +156,16 @@ impl TextArea {
vim_enabled: false,
vim_mode: VimMode::Insert,
vim_operator: None,
editor_keymap: RuntimeKeymap::defaults().editor,
editor_keymap: defaults.editor,
vim_normal_keymap: defaults.vim_normal,
vim_operator_keymap: defaults.vim_operator,
}
}
pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) {
self.editor_keymap = keymap.clone();
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.editor_keymap = keymap.editor.clone();
self.vim_normal_keymap = keymap.vim_normal.clone();
self.vim_operator_keymap = keymap.vim_operator.clone();
}
/// Replace the visible textarea text and clear any existing text elements.
@@ -576,235 +585,167 @@ impl TextArea {
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;
}
_ => {}
if self.vim_normal_keymap.enter_insert.is_pressed(event) {
self.vim_mode = VimMode::Insert;
return;
}
if self.vim_normal_keymap.append_after_cursor.is_pressed(event) {
let next = self.next_atomic_boundary(self.cursor_pos);
self.set_cursor(next);
self.vim_mode = VimMode::Insert;
return;
}
if self.vim_normal_keymap.append_line_end.is_pressed(event) {
self.set_cursor(self.end_of_current_line());
self.vim_mode = VimMode::Insert;
return;
}
if self.vim_normal_keymap.insert_line_start.is_pressed(event) {
self.set_cursor(self.beginning_of_current_line());
self.vim_mode = VimMode::Insert;
return;
}
if self.vim_normal_keymap.open_line_below.is_pressed(event) {
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;
return;
}
if self.vim_normal_keymap.open_line_above.is_pressed(event) {
let bol = self.beginning_of_current_line();
self.insert_str_at(bol, "\n");
self.set_cursor(bol);
self.vim_mode = VimMode::Insert;
return;
}
if self.vim_normal_keymap.move_left.is_pressed(event) {
self.move_cursor_left();
return;
}
if self.vim_normal_keymap.move_right.is_pressed(event) {
self.move_cursor_right();
return;
}
if self.vim_normal_keymap.move_down.is_pressed(event) {
self.move_cursor_down();
return;
}
if self.vim_normal_keymap.move_up.is_pressed(event) {
self.move_cursor_up();
return;
}
if self.vim_normal_keymap.move_word_forward.is_pressed(event) {
self.set_cursor(self.beginning_of_next_word());
return;
}
if self.vim_normal_keymap.move_word_backward.is_pressed(event) {
self.set_cursor(self.beginning_of_previous_word());
return;
}
if self.vim_normal_keymap.move_word_end.is_pressed(event) {
self.set_cursor(self.end_of_next_word());
return;
}
if self.vim_normal_keymap.move_line_start.is_pressed(event) {
self.set_cursor(self.beginning_of_current_line());
return;
}
if self.vim_normal_keymap.move_line_end.is_pressed(event) {
self.set_cursor(self.end_of_current_line());
return;
}
if self.vim_normal_keymap.delete_char.is_pressed(event) {
self.delete_forward_kill(1);
return;
}
if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) {
self.kill_to_end_of_line();
return;
}
if self.vim_normal_keymap.yank_line.is_pressed(event) {
self.yank_current_line();
return;
}
if self.vim_normal_keymap.paste_after.is_pressed(event) {
self.paste_after_cursor();
return;
}
if self
.vim_normal_keymap
.start_delete_operator
.is_pressed(event)
{
self.vim_operator = Some(VimOperator::Delete);
return;
}
if self.vim_normal_keymap.start_yank_operator.is_pressed(event) {
self.vim_operator = Some(VimOperator::Yank);
return;
}
if self.vim_normal_keymap.cancel_operator.is_pressed(event) {
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
}
if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) {
self.delete_current_line();
return true;
}
if op == VimOperator::Yank && self.vim_operator_keymap.yank_line.is_pressed(event) {
self.yank_current_line();
return true;
}
if self.vim_operator_keymap.cancel.is_pressed(event) {
return 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,
if self.vim_operator_keymap.motion_left.is_pressed(event) {
return Some(VimMotion::Left);
}
if self.vim_operator_keymap.motion_right.is_pressed(event) {
return Some(VimMotion::Right);
}
if self.vim_operator_keymap.motion_down.is_pressed(event) {
return Some(VimMotion::Down);
}
if self.vim_operator_keymap.motion_up.is_pressed(event) {
return Some(VimMotion::Up);
}
if self
.vim_operator_keymap
.motion_word_forward
.is_pressed(event)
{
return Some(VimMotion::WordForward);
}
if self
.vim_operator_keymap
.motion_word_backward
.is_pressed(event)
{
return Some(VimMotion::WordBackward);
}
if self.vim_operator_keymap.motion_word_end.is_pressed(event) {
return Some(VimMotion::WordEnd);
}
if self.vim_operator_keymap.motion_line_start.is_pressed(event) {
return Some(VimMotion::LineStart);
}
if self.vim_operator_keymap.motion_line_end.is_pressed(event) {
return Some(VimMotion::LineEnd);
}
None
}
fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) {
+476
View File
@@ -5293,6 +5293,9 @@ impl ChatWidget {
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_vim_enabled(widget.config.tui_vim_mode_default);
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
@@ -5645,6 +5648,469 @@ impl ChatWidget {
self.last_agent_markdown.as_deref()
}
fn dispatch_command(&mut self, cmd: SlashCommand) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.drain_pending_submission_state();
self.request_redraw();
return;
}
match cmd {
SlashCommand::Feedback => {
if !self.config.feedback_enabled {
let params = crate::bottom_pane::feedback_disabled_params();
self.bottom_pane.show_selection_view(params);
self.request_redraw();
return;
}
// Step 1: pick a category (UI built in feedback_view)
let params =
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
self.bottom_pane.show_selection_view(params);
self.request_redraw();
}
SlashCommand::New => {
self.app_event_tx.send(AppEvent::NewSession);
}
SlashCommand::Clear => {
self.app_event_tx.send(AppEvent::ClearUi);
}
SlashCommand::Resume => {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
if init_target.exists() {
let message = format!(
"{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it."
);
self.add_info_message(message, /*hint*/ None);
return;
}
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
self.submit_user_message(INIT_PROMPT.to_string().into());
}
SlashCommand::Compact => {
self.clear_token_usage();
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.app_event_tx.compact();
}
SlashCommand::Review => {
self.open_review_popup();
}
SlashCommand::Rename => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
self.show_rename_prompt();
}
SlashCommand::Model => {
self.open_model_popup();
}
SlashCommand::Fast => {
let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
}
SlashCommand::Realtime => {
if !self.realtime_conversation_enabled() {
return;
}
if self.realtime_conversation.is_live() {
self.stop_realtime_conversation_from_ui();
} else {
self.start_realtime_conversation();
}
}
SlashCommand::Settings => {
if !self.realtime_audio_device_selection_enabled() {
return;
}
self.open_realtime_audio_popup();
}
SlashCommand::Personality => {
self.open_personality_popup();
}
SlashCommand::Plan => {
if !self.collaboration_modes_enabled() {
self.add_info_message(
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /plan.".to_string()),
);
return;
}
if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) {
self.set_collaboration_mask(mask);
} else {
self.add_info_message(
"Plan mode unavailable right now.".to_string(),
/*hint*/ None,
);
}
}
SlashCommand::Collab => {
if !self.collaboration_modes_enabled() {
self.add_info_message(
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /collab.".to_string()),
);
return;
}
self.open_collaboration_modes_popup();
}
SlashCommand::Agent | SlashCommand::MultiAgents => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
}
SlashCommand::Approvals => {
self.open_permissions_popup();
}
SlashCommand::Permissions => {
self.open_permissions_popup();
}
SlashCommand::Vim => {
self.toggle_vim_mode_and_notify();
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
let windows_degraded_sandbox_enabled =
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
if !windows_degraded_sandbox_enabled
|| !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
{
// This command should not be visible/recognized outside degraded mode,
// but guard anyway in case something dispatches it directly.
return;
}
let Some(preset) = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
else {
// Avoid panicking in interactive UI; treat this as a recoverable
// internal error.
self.add_error_message(
"Internal error: missing the 'auto' approval preset.".to_string(),
);
return;
};
if let Err(err) = self
.config
.permissions
.approval_policy
.can_set(&preset.approval)
{
self.add_error_message(err.to_string());
return;
}
self.session_telemetry.counter(
"codex.windows_sandbox.setup_elevated_sandbox_command",
/*inc*/ 1,
&[],
);
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
}
#[cfg(not(target_os = "windows"))]
{
let _ = &self.session_telemetry;
// Not supported; on non-Windows this command should never be reachable.
};
}
SlashCommand::SandboxReadRoot => {
self.add_error_message(
"Usage: /sandbox-add-read-dir <absolute-directory-path>".to_string(),
);
}
SlashCommand::Experimental => {
self.open_experimental_popup();
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_quit_without_confirmation();
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(
&self.config.codex_home,
self.config.cli_auth_credentials_store_mode,
) {
tracing::error!("failed to logout: {e}");
}
self.request_quit_without_confirmation();
}
// SlashCommand::Undo => {
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
// }
SlashCommand::Copy => {
self.copy_last_agent_markdown();
}
SlashCommand::Diff => {
self.add_diff_in_progress();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let text = match get_git_diff().await {
Ok((is_git_repo, diff_text)) => {
if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
}
}
Err(e) => format!("Failed to compute diff: {e}"),
};
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::Mention => {
self.insert_str("@");
}
SlashCommand::Skills => {
self.open_skills_menu();
}
SlashCommand::Status => {
if self.should_prefetch_rate_limits() {
let request_id = self.next_rate_limit_refresh_request_id();
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
});
} else {
self.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
}
}
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Title => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
SlashCommand::Theme => {
self.open_theme_picker();
}
SlashCommand::Ps => {
self.add_ps_output();
}
SlashCommand::Stop => {
self.clean_background_terminals();
}
SlashCommand::MemoryDrop => {
self.add_app_server_stub_message("Memory maintenance");
}
SlashCommand::MemoryUpdate => {
self.add_app_server_stub_message("Memory maintenance");
}
SlashCommand::Mcp => {
self.add_mcp_output();
}
SlashCommand::Apps => {
self.add_connectors_output();
}
SlashCommand::Plugins => {
self.add_plugins_output();
}
SlashCommand::Rollout => {
if let Some(path) = self.rollout_path() {
self.add_info_message(
format!("Current rollout path: {}", path.display()),
/*hint*/ None,
);
} else {
self.add_info_message(
"Rollout path is not available yet.".to_string(),
/*hint*/ None,
);
}
}
SlashCommand::TestApproval => {
use std::collections::HashMap;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::FileChange;
self.on_apply_patch_approval_request(
"1".to_string(),
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
turn_id: "turn-1".to_string(),
changes: HashMap::from([
(
PathBuf::from("/tmp/test.txt"),
FileChange::Add {
content: "test".to_string(),
},
),
(
PathBuf::from("/tmp/test2.txt"),
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
},
),
]),
reason: None,
grant_root: Some(PathBuf::from("/tmp")),
},
);
}
}
}
fn dispatch_command_with_args(
&mut self,
cmd: SlashCommand,
args: String,
_text_elements: Vec<TextElement>,
) {
if !cmd.supports_inline_args() {
self.dispatch_command(cmd);
return;
}
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
return;
}
let trimmed = args.trim();
match cmd {
SlashCommand::Fast => {
if trimmed.is_empty() {
self.dispatch_command(cmd);
return;
}
match trimmed.to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"off" => self.set_service_tier_selection(/*service_tier*/ None),
"status" => {
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
{
"on"
} else {
"off"
};
self.add_info_message(
format!("Fast mode is {status}."),
/*hint*/ None,
);
}
_ => {
self.add_error_message("Usage: /fast [on|off|status]".to_string());
}
}
}
SlashCommand::Rename if !trimmed.is_empty() => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args)
else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
};
self.app_event_tx.set_thread_name(name);
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Plan if !trimmed.is_empty() => {
self.dispatch_command(cmd);
if self.active_mode_kind() != ModeKind::Plan {
return;
}
let Some((prepared_args, prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ true)
else {
return;
};
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
text: prepared_args,
local_images,
remote_image_urls,
text_elements: prepared_elements,
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
SlashCommand::Review if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.submit_op(AppCommand::review(ReviewRequest {
target: ReviewTarget::Custom {
instructions: prepared_args,
},
user_facing_hint: None,
}));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Resume if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(prepared_args));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
path: prepared_args,
});
self.bottom_pane.drain_pending_submission_state();
}
_ => self.dispatch_command(cmd),
}
}
fn show_rename_prompt(&mut self) {
if !self.ensure_thread_rename_allowed() {
return;
@@ -10824,6 +11290,16 @@ impl ChatWidget {
self.bottom_pane.is_task_running()
}
pub(crate) fn toggle_vim_mode_and_notify(&mut self) {
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);
}
pub(crate) fn submit_user_message_with_mode(
&mut self,
text: String,
+35 -7
View File
@@ -1342,13 +1342,43 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
#[tokio::test]
async fn collaboration_modes_defaults_to_code_on_startup() {
let chat = make_startup_chat_with_cli_overrides(vec![(
"features.collaboration_modes".to_string(),
TomlValue::Boolean(true),
)])
.await;
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
assert_eq!(
chat.current_model(),
codex_core::test_support::get_model_offline(chat.config.model.as_deref())
);
}
#[tokio::test]
async fn vim_mode_default_disabled_starts_composer_in_insert_mode() {
let chat = make_startup_chat_with_cli_overrides(Vec::new()).await;
assert!(!chat.bottom_pane.composer_is_vim_enabled());
}
#[tokio::test]
async fn vim_mode_default_enabled_starts_composer_in_normal_mode() {
let chat = make_startup_chat_with_cli_overrides(vec![(
"tui.vim_mode_default".to_string(),
TomlValue::Boolean(true),
)])
.await;
assert!(chat.bottom_pane.composer_is_vim_enabled());
assert!(!chat.bottom_pane.composer_is_vim_insert());
}
async fn make_startup_chat_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> ChatWidget {
let codex_home = tempdir().expect("tempdir");
let cfg = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![(
"features.collaboration_modes".to_string(),
TomlValue::Boolean(true),
)])
.cli_overrides(cli_overrides)
.build()
.await
.expect("config");
@@ -1373,9 +1403,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
session_telemetry,
};
let chat = ChatWidget::new_with_app_event(init);
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
assert_eq!(chat.current_model(), resolved_model);
ChatWidget::new_with_app_event(init)
}
#[tokio::test]
+264
View File
@@ -40,6 +40,8 @@ pub(crate) struct RuntimeKeymap {
pub(crate) chat: ChatKeymap,
pub(crate) composer: ComposerKeymap,
pub(crate) editor: EditorKeymap,
pub(crate) vim_normal: VimNormalKeymap,
pub(crate) vim_operator: VimOperatorKeymap,
pub(crate) pager: PagerKeymap,
pub(crate) list: ListKeymap,
pub(crate) approval: ApprovalKeymap,
@@ -52,6 +54,8 @@ pub(crate) struct AppKeymap {
pub(crate) open_transcript: Vec<KeyBinding>,
/// Open external editor for the current draft.
pub(crate) open_external_editor: Vec<KeyBinding>,
/// Toggle Vim mode for the composer input.
pub(crate) toggle_vim_mode: Vec<KeyBinding>,
}
#[derive(Clone, Debug)]
@@ -96,6 +100,48 @@ pub(crate) struct EditorKeymap {
pub(crate) yank: Vec<KeyBinding>,
}
#[derive(Clone, Debug)]
pub(crate) struct VimNormalKeymap {
pub(crate) enter_insert: Vec<KeyBinding>,
pub(crate) append_after_cursor: Vec<KeyBinding>,
pub(crate) append_line_end: Vec<KeyBinding>,
pub(crate) insert_line_start: Vec<KeyBinding>,
pub(crate) open_line_below: Vec<KeyBinding>,
pub(crate) open_line_above: Vec<KeyBinding>,
pub(crate) move_left: Vec<KeyBinding>,
pub(crate) move_right: Vec<KeyBinding>,
pub(crate) move_up: Vec<KeyBinding>,
pub(crate) move_down: Vec<KeyBinding>,
pub(crate) move_word_forward: Vec<KeyBinding>,
pub(crate) move_word_backward: Vec<KeyBinding>,
pub(crate) move_word_end: Vec<KeyBinding>,
pub(crate) move_line_start: Vec<KeyBinding>,
pub(crate) move_line_end: Vec<KeyBinding>,
pub(crate) delete_char: Vec<KeyBinding>,
pub(crate) delete_to_line_end: Vec<KeyBinding>,
pub(crate) yank_line: Vec<KeyBinding>,
pub(crate) paste_after: Vec<KeyBinding>,
pub(crate) start_delete_operator: Vec<KeyBinding>,
pub(crate) start_yank_operator: Vec<KeyBinding>,
pub(crate) cancel_operator: Vec<KeyBinding>,
}
#[derive(Clone, Debug)]
pub(crate) struct VimOperatorKeymap {
pub(crate) delete_line: Vec<KeyBinding>,
pub(crate) yank_line: Vec<KeyBinding>,
pub(crate) motion_left: Vec<KeyBinding>,
pub(crate) motion_right: Vec<KeyBinding>,
pub(crate) motion_up: Vec<KeyBinding>,
pub(crate) motion_down: Vec<KeyBinding>,
pub(crate) motion_word_forward: Vec<KeyBinding>,
pub(crate) motion_word_backward: Vec<KeyBinding>,
pub(crate) motion_word_end: Vec<KeyBinding>,
pub(crate) motion_line_start: Vec<KeyBinding>,
pub(crate) motion_line_end: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
}
/// Pager/overlay keybindings for transcript and static help views.
#[derive(Clone, Debug)]
pub(crate) struct PagerKeymap {
@@ -278,6 +324,11 @@ impl RuntimeKeymap {
&defaults.app.open_external_editor,
"tui.keymap.global.open_external_editor",
)?,
toggle_vim_mode: resolve_bindings(
keymap.global.toggle_vim_mode.as_ref(),
&defaults.app.toggle_vim_mode,
"tui.keymap.global.toggle_vim_mode",
)?,
};
let chat = ChatKeymap {
@@ -320,6 +371,61 @@ impl RuntimeKeymap {
yank: resolve_local!(keymap, defaults, editor, yank),
};
let vim_normal = VimNormalKeymap {
enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert),
append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor),
append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end),
insert_line_start: resolve_local!(keymap, defaults, vim_normal, insert_line_start),
open_line_below: resolve_local!(keymap, defaults, vim_normal, open_line_below),
open_line_above: resolve_local!(keymap, defaults, vim_normal, open_line_above),
move_left: resolve_local!(keymap, defaults, vim_normal, move_left),
move_right: resolve_local!(keymap, defaults, vim_normal, move_right),
move_up: resolve_local!(keymap, defaults, vim_normal, move_up),
move_down: resolve_local!(keymap, defaults, vim_normal, move_down),
move_word_forward: resolve_local!(keymap, defaults, vim_normal, move_word_forward),
move_word_backward: resolve_local!(keymap, defaults, vim_normal, move_word_backward),
move_word_end: resolve_local!(keymap, defaults, vim_normal, move_word_end),
move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start),
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after),
start_delete_operator: resolve_local!(
keymap,
defaults,
vim_normal,
start_delete_operator
),
start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator),
cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator),
};
let vim_operator = VimOperatorKeymap {
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line),
motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left),
motion_right: resolve_local!(keymap, defaults, vim_operator, motion_right),
motion_up: resolve_local!(keymap, defaults, vim_operator, motion_up),
motion_down: resolve_local!(keymap, defaults, vim_operator, motion_down),
motion_word_forward: resolve_local!(
keymap,
defaults,
vim_operator,
motion_word_forward
),
motion_word_backward: resolve_local!(
keymap,
defaults,
vim_operator,
motion_word_backward
),
motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end),
motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start),
motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end),
cancel: resolve_local!(keymap, defaults, vim_operator, cancel),
};
let pager = PagerKeymap {
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
@@ -369,6 +475,8 @@ impl RuntimeKeymap {
chat,
composer,
editor,
vim_normal,
vim_operator,
pager,
list,
approval,
@@ -395,6 +503,7 @@ impl RuntimeKeymap {
app: AppKeymap {
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))],
toggle_vim_mode: default_bindings![],
},
chat: ChatKeymap {
edit_previous_message: default_bindings![plain(KeyCode::Esc)],
@@ -449,6 +558,62 @@ impl RuntimeKeymap {
kill_line_end: default_bindings![ctrl(KeyCode::Char('k'))],
yank: default_bindings![ctrl(KeyCode::Char('y'))],
},
vim_normal: VimNormalKeymap {
enter_insert: default_bindings![plain(KeyCode::Char('i'))],
append_after_cursor: default_bindings![plain(KeyCode::Char('a'))],
append_line_end: default_bindings![
shift(KeyCode::Char('a')),
plain(KeyCode::Char('A'))
],
insert_line_start: default_bindings![
shift(KeyCode::Char('i')),
plain(KeyCode::Char('I'))
],
open_line_below: default_bindings![plain(KeyCode::Char('o'))],
open_line_above: default_bindings![
shift(KeyCode::Char('o')),
plain(KeyCode::Char('O'))
],
move_left: default_bindings![plain(KeyCode::Char('h'))],
move_right: default_bindings![plain(KeyCode::Char('l'))],
move_up: default_bindings![plain(KeyCode::Char('k'))],
move_down: default_bindings![plain(KeyCode::Char('j'))],
move_word_forward: default_bindings![plain(KeyCode::Char('w'))],
move_word_backward: default_bindings![plain(KeyCode::Char('b'))],
move_word_end: default_bindings![plain(KeyCode::Char('e'))],
move_line_start: default_bindings![plain(KeyCode::Char('0'))],
move_line_end: default_bindings![
plain(KeyCode::Char('$')),
shift(KeyCode::Char('$'))
],
delete_char: default_bindings![plain(KeyCode::Char('x'))],
delete_to_line_end: default_bindings![
shift(KeyCode::Char('d')),
plain(KeyCode::Char('D'))
],
yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))],
paste_after: default_bindings![plain(KeyCode::Char('p'))],
start_delete_operator: default_bindings![plain(KeyCode::Char('d'))],
start_yank_operator: default_bindings![plain(KeyCode::Char('y'))],
cancel_operator: default_bindings![plain(KeyCode::Esc)],
},
vim_operator: VimOperatorKeymap {
delete_line: default_bindings![plain(KeyCode::Char('d'))],
yank_line: default_bindings![plain(KeyCode::Char('y'))],
motion_left: default_bindings![plain(KeyCode::Char('h'))],
motion_right: default_bindings![plain(KeyCode::Char('l'))],
motion_up: default_bindings![plain(KeyCode::Char('k'))],
motion_down: default_bindings![plain(KeyCode::Char('j'))],
motion_word_forward: default_bindings![plain(KeyCode::Char('w'))],
motion_word_backward: default_bindings![plain(KeyCode::Char('b'))],
motion_word_end: default_bindings![plain(KeyCode::Char('e'))],
motion_line_start: default_bindings![plain(KeyCode::Char('0'))],
motion_line_end: default_bindings![
plain(KeyCode::Char('$')),
shift(KeyCode::Char('$'))
],
cancel: default_bindings![plain(KeyCode::Esc)],
},
pager: PagerKeymap {
scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))],
scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))],
@@ -551,6 +716,7 @@ impl RuntimeKeymap {
"open_external_editor",
self.app.open_external_editor.as_slice(),
),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
(
"edit_previous_message",
self.chat.edit_previous_message.as_slice(),
@@ -570,6 +736,7 @@ impl RuntimeKeymap {
"open_external_editor",
self.app.open_external_editor.as_slice(),
),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
("composer.submit", self.composer.submit.as_slice()),
("composer.queue", self.composer.queue.as_slice()),
(
@@ -607,6 +774,103 @@ impl RuntimeKeymap {
],
)?;
validate_unique(
"vim_normal",
[
("enter_insert", self.vim_normal.enter_insert.as_slice()),
(
"append_after_cursor",
self.vim_normal.append_after_cursor.as_slice(),
),
(
"append_line_end",
self.vim_normal.append_line_end.as_slice(),
),
(
"insert_line_start",
self.vim_normal.insert_line_start.as_slice(),
),
(
"open_line_below",
self.vim_normal.open_line_below.as_slice(),
),
(
"open_line_above",
self.vim_normal.open_line_above.as_slice(),
),
("move_left", self.vim_normal.move_left.as_slice()),
("move_right", self.vim_normal.move_right.as_slice()),
("move_up", self.vim_normal.move_up.as_slice()),
("move_down", self.vim_normal.move_down.as_slice()),
(
"move_word_forward",
self.vim_normal.move_word_forward.as_slice(),
),
(
"move_word_backward",
self.vim_normal.move_word_backward.as_slice(),
),
("move_word_end", self.vim_normal.move_word_end.as_slice()),
(
"move_line_start",
self.vim_normal.move_line_start.as_slice(),
),
("move_line_end", self.vim_normal.move_line_end.as_slice()),
("delete_char", self.vim_normal.delete_char.as_slice()),
(
"delete_to_line_end",
self.vim_normal.delete_to_line_end.as_slice(),
),
("yank_line", self.vim_normal.yank_line.as_slice()),
("paste_after", self.vim_normal.paste_after.as_slice()),
(
"start_delete_operator",
self.vim_normal.start_delete_operator.as_slice(),
),
(
"start_yank_operator",
self.vim_normal.start_yank_operator.as_slice(),
),
(
"cancel_operator",
self.vim_normal.cancel_operator.as_slice(),
),
],
)?;
validate_unique(
"vim_operator",
[
("delete_line", self.vim_operator.delete_line.as_slice()),
("yank_line", self.vim_operator.yank_line.as_slice()),
("motion_left", self.vim_operator.motion_left.as_slice()),
("motion_right", self.vim_operator.motion_right.as_slice()),
("motion_up", self.vim_operator.motion_up.as_slice()),
("motion_down", self.vim_operator.motion_down.as_slice()),
(
"motion_word_forward",
self.vim_operator.motion_word_forward.as_slice(),
),
(
"motion_word_backward",
self.vim_operator.motion_word_backward.as_slice(),
),
(
"motion_word_end",
self.vim_operator.motion_word_end.as_slice(),
),
(
"motion_line_start",
self.vim_operator.motion_line_start.as_slice(),
),
(
"motion_line_end",
self.vim_operator.motion_line_end.as_slice(),
),
("cancel", self.vim_operator.cancel.as_slice()),
],
)?;
validate_unique(
"pager",
[