mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
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:
committed by
Felipe Coury
parent
d49e77a7c2
commit
cb3e5baa73
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user