mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add vim composer mode (#18595)
## Why Codex now has configurable TUI keymaps, but the composer still behaves like a plain text field. Users who prefer modal editing need a way to keep Vim muscle memory while drafting prompts, and the keymap picker needs to expose Vim-specific actions if those bindings are configurable instead of hardcoded. ## What Changed - Adds composer Vim mode with insert/normal state, common normal-mode movement and editing commands, `d`/`y` operator-pending flows, and mode-aware footer and cursor indicators. - Adds `/vim`, an optional global `toggle_vim_mode` binding, and `tui.vim_mode_default` so Vim mode can be toggled per session or enabled as the default composer state. - Extends runtime and config keymaps with `vim_normal` and `vim_operator` contexts, exposes those contexts in `/keymap`, refreshes the config schema, and validates Vim bindings separately. - Integrates Vim normal mode with existing composer behavior: `/` opens slash command entry, `!` enters shell mode, `j`/`k` navigate history at history boundaries, successful submissions reset back to normal mode, and paste burst handling remains insert-mode only. - Teaches the TUI render path to apply and restore cursor style so Vim insert mode can use a bar cursor without leaving the terminal in that state after exit. ## Validation - `cargo test -p codex-tui keymap -- --nocapture` on the keymap/Vim coverage - `cargo insta pending-snapshots` ## Docs This introduces user-facing `/vim`, `tui.vim_mode_default`, and Vim keymap contexts under `tui.keymap`, so the public CLI configuration and slash-command docs should be updated before the feature ships.
This commit is contained in:
committed by
GitHub
Unverified
parent
a5ebedef67
commit
b6f81257f8
@@ -102,6 +102,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.
|
||||
@@ -173,6 +175,95 @@ pub struct TuiEditorKeymap {
|
||||
pub yank: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Vim normal-mode keybindings for modal editing inside text areas.
|
||||
///
|
||||
/// Actions that use uppercase letters (like `A` for append-line-end) should
|
||||
/// be specified as `shift-a` in config; the runtime matcher handles
|
||||
/// cross-terminal shift-reporting differences automatically.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiVimNormalKeymap {
|
||||
/// Enter insert mode at cursor (`i`).
|
||||
pub enter_insert: Option<KeybindingsSpec>,
|
||||
/// Enter insert mode after cursor (`a`).
|
||||
pub append_after_cursor: Option<KeybindingsSpec>,
|
||||
/// Enter insert mode at end of line (`A`).
|
||||
pub append_line_end: Option<KeybindingsSpec>,
|
||||
/// Enter insert mode at first non-blank of line (`I`).
|
||||
pub insert_line_start: Option<KeybindingsSpec>,
|
||||
/// Open a new line below and enter insert mode (`o`).
|
||||
pub open_line_below: Option<KeybindingsSpec>,
|
||||
/// Open a new line above and enter insert mode (`O`).
|
||||
pub open_line_above: Option<KeybindingsSpec>,
|
||||
/// Move cursor left (`h`).
|
||||
pub move_left: Option<KeybindingsSpec>,
|
||||
/// Move cursor right (`l`).
|
||||
pub move_right: Option<KeybindingsSpec>,
|
||||
/// Move cursor up (`k`), or recall older composer history at history boundaries.
|
||||
pub move_up: Option<KeybindingsSpec>,
|
||||
/// Move cursor down (`j`), or recall newer composer history at history boundaries.
|
||||
pub move_down: Option<KeybindingsSpec>,
|
||||
/// Move cursor to start of next word (`w`).
|
||||
pub move_word_forward: Option<KeybindingsSpec>,
|
||||
/// Move cursor to start of previous word (`b`).
|
||||
pub move_word_backward: Option<KeybindingsSpec>,
|
||||
/// Move cursor to end of current/next word (`e`).
|
||||
pub move_word_end: Option<KeybindingsSpec>,
|
||||
/// Move cursor to start of line (`0`).
|
||||
pub move_line_start: Option<KeybindingsSpec>,
|
||||
/// Move cursor to end of line (`$`).
|
||||
pub move_line_end: Option<KeybindingsSpec>,
|
||||
/// Delete character under cursor (`x`).
|
||||
pub delete_char: Option<KeybindingsSpec>,
|
||||
/// Delete from cursor to end of line (`D`).
|
||||
pub delete_to_line_end: Option<KeybindingsSpec>,
|
||||
/// Yank the entire line (`Y`).
|
||||
pub yank_line: Option<KeybindingsSpec>,
|
||||
/// Paste after cursor (`p`).
|
||||
pub paste_after: Option<KeybindingsSpec>,
|
||||
/// Begin delete operator; next key selects motion (`d`).
|
||||
pub start_delete_operator: Option<KeybindingsSpec>,
|
||||
/// Begin yank operator; next key selects motion (`y`).
|
||||
pub start_yank_operator: Option<KeybindingsSpec>,
|
||||
/// Cancel a pending operator and return to normal mode.
|
||||
pub cancel_operator: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Vim operator-pending keybindings for modal editing inside text areas.
|
||||
///
|
||||
/// This context is active only while waiting for a motion after `d` or `y`.
|
||||
/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing
|
||||
/// `Esc` cancels the pending operator and returns to normal mode without
|
||||
/// modifying text.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiVimOperatorKeymap {
|
||||
/// Repeat delete operator to delete the whole line (`dd`).
|
||||
pub delete_line: Option<KeybindingsSpec>,
|
||||
/// Repeat yank operator to yank the whole line (`yy`).
|
||||
pub yank_line: Option<KeybindingsSpec>,
|
||||
/// Motion: left (`h`).
|
||||
pub motion_left: Option<KeybindingsSpec>,
|
||||
/// Motion: right (`l`).
|
||||
pub motion_right: Option<KeybindingsSpec>,
|
||||
/// Motion: up one line (`k`).
|
||||
pub motion_up: Option<KeybindingsSpec>,
|
||||
/// Motion: down one line (`j`).
|
||||
pub motion_down: Option<KeybindingsSpec>,
|
||||
/// Motion: to start of next word (`w`).
|
||||
pub motion_word_forward: Option<KeybindingsSpec>,
|
||||
/// Motion: to start of previous word (`b`).
|
||||
pub motion_word_backward: Option<KeybindingsSpec>,
|
||||
/// Motion: to end of current/next word (`e`).
|
||||
pub motion_word_end: Option<KeybindingsSpec>,
|
||||
/// Motion: to start of line (`0`).
|
||||
pub motion_line_start: Option<KeybindingsSpec>,
|
||||
/// Motion: to end of line (`$`).
|
||||
pub motion_line_end: Option<KeybindingsSpec>,
|
||||
/// Cancel the pending operator and return to normal mode.
|
||||
pub cancel: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Pager context keybindings for transcript and static overlays.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -261,6 +352,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::TuiGlobalKeymap;
|
||||
pub use crate::tui_keymap::TuiKeymap;
|
||||
pub use crate::tui_keymap::TuiListKeymap;
|
||||
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 = 2;
|
||||
@@ -609,6 +611,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.
|
||||
|
||||
@@ -2414,7 +2414,8 @@
|
||||
"open_transcript": null,
|
||||
"queue": null,
|
||||
"submit": null,
|
||||
"toggle_shortcuts": null
|
||||
"toggle_shortcuts": null,
|
||||
"toggle_vim_mode": null
|
||||
},
|
||||
"list": {
|
||||
"accept": null,
|
||||
@@ -2433,6 +2434,44 @@
|
||||
"page_up": null,
|
||||
"scroll_down": null,
|
||||
"scroll_up": null
|
||||
},
|
||||
"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."
|
||||
@@ -2505,6 +2544,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"
|
||||
@@ -2852,6 +2896,14 @@
|
||||
}
|
||||
],
|
||||
"description": "Toggle the composer shortcut overlay."
|
||||
},
|
||||
"toggle_vim_mode": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Toggle Vim mode for the composer input."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -2941,7 +2993,8 @@
|
||||
"open_transcript": null,
|
||||
"queue": null,
|
||||
"submit": null,
|
||||
"toggle_shortcuts": null
|
||||
"toggle_shortcuts": null,
|
||||
"toggle_vim_mode": null
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
@@ -2975,6 +3028,58 @@
|
||||
"scroll_down": null,
|
||||
"scroll_up": null
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -3105,6 +3210,292 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiVimNormalKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Vim normal-mode keybindings for modal editing inside text areas.\n\nActions that use uppercase letters (like `A` for append-line-end) should be specified as `shift-a` in config; the runtime matcher handles cross-terminal shift-reporting differences automatically.",
|
||||
"properties": {
|
||||
"append_after_cursor": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Enter insert mode after cursor (`a`)."
|
||||
},
|
||||
"append_line_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Enter insert mode at end of line (`A`)."
|
||||
},
|
||||
"cancel_operator": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Cancel a pending operator and return to normal mode."
|
||||
},
|
||||
"delete_char": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Delete character under cursor (`x`)."
|
||||
},
|
||||
"delete_to_line_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Delete from cursor to end of line (`D`)."
|
||||
},
|
||||
"enter_insert": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Enter insert mode at cursor (`i`)."
|
||||
},
|
||||
"insert_line_start": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Enter insert mode at first non-blank of line (`I`)."
|
||||
},
|
||||
"move_down": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor down (`j`), or recall newer composer history at history boundaries."
|
||||
},
|
||||
"move_left": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor left (`h`)."
|
||||
},
|
||||
"move_line_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to end of line (`$`)."
|
||||
},
|
||||
"move_line_start": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to start of line (`0`)."
|
||||
},
|
||||
"move_right": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor right (`l`)."
|
||||
},
|
||||
"move_up": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor up (`k`), or recall older composer history at history boundaries."
|
||||
},
|
||||
"move_word_backward": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to start of previous word (`b`)."
|
||||
},
|
||||
"move_word_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to end of current/next word (`e`)."
|
||||
},
|
||||
"move_word_forward": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Move cursor to start of next word (`w`)."
|
||||
},
|
||||
"open_line_above": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Open a new line above and enter insert mode (`O`)."
|
||||
},
|
||||
"open_line_below": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Open a new line below and enter insert mode (`o`)."
|
||||
},
|
||||
"paste_after": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Paste after cursor (`p`)."
|
||||
},
|
||||
"start_delete_operator": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Begin delete operator; next key selects motion (`d`)."
|
||||
},
|
||||
"start_yank_operator": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Begin yank operator; next key selects motion (`y`)."
|
||||
},
|
||||
"yank_line": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Yank the entire line (`Y`)."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiVimOperatorKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d` or `y`. Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.",
|
||||
"properties": {
|
||||
"cancel": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Cancel the pending operator and return to normal mode."
|
||||
},
|
||||
"delete_line": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Repeat delete operator to delete the whole line (`dd`)."
|
||||
},
|
||||
"motion_down": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: down one line (`j`)."
|
||||
},
|
||||
"motion_left": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: left (`h`)."
|
||||
},
|
||||
"motion_line_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: to end of line (`$`)."
|
||||
},
|
||||
"motion_line_start": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: to start of line (`0`)."
|
||||
},
|
||||
"motion_right": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: right (`l`)."
|
||||
},
|
||||
"motion_up": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: up one line (`k`)."
|
||||
},
|
||||
"motion_word_backward": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: to start of previous word (`b`)."
|
||||
},
|
||||
"motion_word_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: to end of current/next word (`e`)."
|
||||
},
|
||||
"motion_word_forward": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Motion: to start of next word (`w`)."
|
||||
},
|
||||
"yank_line": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Repeat yank operator to yank the whole line (`yy`)."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UriBasedFileOpener": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -549,6 +549,7 @@ fn config_toml_deserializes_model_availability_nux() {
|
||||
notification_settings: TuiNotificationSettings::default(),
|
||||
animations: true,
|
||||
show_tooltips: true,
|
||||
vim_mode_default: false,
|
||||
alternate_screen: AltScreenMode::default(),
|
||||
status_line: None,
|
||||
terminal_title: None,
|
||||
@@ -598,6 +599,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#"
|
||||
@@ -2062,6 +2092,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,
|
||||
@@ -6381,6 +6412,8 @@ 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,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
analytics_enabled: Some(true),
|
||||
@@ -6390,7 +6423,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
},
|
||||
o3_profile_config
|
||||
@@ -6577,6 +6609,8 @@ 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,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
analytics_enabled: Some(true),
|
||||
@@ -6586,7 +6620,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -6727,6 +6760,8 @@ 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,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
analytics_enabled: Some(false),
|
||||
@@ -6736,7 +6771,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
@@ -6862,6 +6896,8 @@ 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,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
model_availability_nux: ModelAvailabilityNuxConfig::default(),
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
analytics_enabled: Some(true),
|
||||
@@ -6871,7 +6907,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
tui_status_line: None,
|
||||
tui_terminal_title: None,
|
||||
tui_theme: None,
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
otel: OtelConfig::default(),
|
||||
};
|
||||
|
||||
|
||||
@@ -507,6 +507,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.
|
||||
@@ -2997,6 +3000,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()
|
||||
|
||||
@@ -194,6 +194,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
|
||||
tui_theme: None,
|
||||
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
|
||||
tui_keymap: TuiKeymap::default(),
|
||||
tui_vim_mode_default: false,
|
||||
cwd,
|
||||
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
|
||||
@@ -1100,15 +1100,19 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
self.chat_widget.desired_height(tui.terminal.size()?.width);
|
||||
if terminal_resize_reflow_enabled {
|
||||
tui.draw_with_resize_reflow(desired_height, |frame| {
|
||||
self.chat_widget.render(frame.area(), frame.buffer);
|
||||
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
|
||||
let area = frame.area();
|
||||
self.chat_widget.render(area, frame.buffer);
|
||||
if let Some((x, y)) = self.chat_widget.cursor_pos(area) {
|
||||
frame.set_cursor_style(self.chat_widget.cursor_style(area));
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
})?;
|
||||
} else {
|
||||
tui.draw(desired_height, |frame| {
|
||||
self.chat_widget.render(frame.area(), frame.buffer);
|
||||
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
|
||||
let area = frame.area();
|
||||
self.chat_widget.render(area, frame.buffer);
|
||||
if let Some((x, y)) = self.chat_widget.cursor_pos(area) {
|
||||
frame.set_cursor_style(self.chat_widget.cursor_style(area));
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -122,6 +122,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.app.open_transcript.is_pressed(key_event) {
|
||||
// Enter alternate screen and set viewport to full size.
|
||||
let _ = tui.enter_alt_screen();
|
||||
@@ -152,7 +157,7 @@ impl App {
|
||||
// with the composer focused and empty. In any other state, forward
|
||||
// Esc so the active UI (e.g. status indicator, modals, popups)
|
||||
// handles it.
|
||||
if self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() {
|
||||
if self.should_handle_backtrack_esc(key_event) {
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
} else {
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
@@ -206,6 +211,12 @@ impl App {
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool {
|
||||
self.chat_widget.is_normal_backtrack_mode()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
&& !self.chat_widget.should_handle_vim_insert_escape(key_event)
|
||||
}
|
||||
|
||||
pub(super) fn refresh_status_line(&mut self) {
|
||||
self.chat_widget.refresh_status_line();
|
||||
}
|
||||
|
||||
@@ -5079,6 +5079,32 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() {
|
||||
let mut app = make_test_app().await;
|
||||
let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE);
|
||||
|
||||
assert!(app.chat_widget.composer_is_empty());
|
||||
assert!(app.should_handle_backtrack_esc(esc));
|
||||
|
||||
app.chat_widget.toggle_vim_mode_and_notify();
|
||||
assert!(app.should_handle_backtrack_esc(esc));
|
||||
|
||||
app.chat_widget
|
||||
.handle_key_event(crossterm::event::KeyEvent::new(
|
||||
crossterm::event::KeyCode::Char('i'),
|
||||
KeyModifiers::NONE,
|
||||
));
|
||||
assert!(app.chat_widget.should_handle_vim_insert_escape(esc));
|
||||
assert!(!app.should_handle_backtrack_esc(esc));
|
||||
|
||||
app.chat_widget.handle_key_event(esc);
|
||||
|
||||
assert!(!app.backtrack.primed);
|
||||
assert!(!app.chat_widget.should_handle_vim_insert_escape(esc));
|
||||
assert!(app.should_handle_backtrack_esc(esc));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_summary_skips_when_no_usage_or_resume_hint() {
|
||||
assert!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -572,6 +572,32 @@ mod tests {
|
||||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_normal_history_search_preview_places_cursor_on_last_char() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
composer
|
||||
.history
|
||||
.record_local_submission(HistoryEntry::new("git status".to_string()));
|
||||
composer.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
for ch in ['g', 'i', 't'] {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
assert_eq!(composer.textarea.text(), "git status");
|
||||
assert_eq!(composer.textarea.cursor(), "git status".len() - 1);
|
||||
assert_eq!(composer.footer_mode(), FooterMode::HistorySearch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_search_stays_on_single_match_at_boundaries() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -74,8 +74,6 @@ pub(crate) struct FooterProps {
|
||||
///
|
||||
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
|
||||
pub(crate) quit_shortcut_key: KeyBinding,
|
||||
pub(crate) context_window_percent: Option<i64>,
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
pub(crate) status_line_value: Option<Line<'static>>,
|
||||
pub(crate) status_line_enabled: bool,
|
||||
pub(crate) key_hints: FooterKeyHints,
|
||||
@@ -1243,11 +1241,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn snapshot_footer_with_context(
|
||||
name: &str,
|
||||
props: FooterProps,
|
||||
percent: Option<i64>,
|
||||
used_tokens: Option<i64>,
|
||||
) {
|
||||
snapshot_footer_with_mode_indicator_and_context(
|
||||
name,
|
||||
/*width*/ 80,
|
||||
&props,
|
||||
/*collaboration_mode_indicator*/ None,
|
||||
context_window_line(percent, used_tokens),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_footer_frame<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
height: u16,
|
||||
props: &FooterProps,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
context_line: Line<'static>,
|
||||
) {
|
||||
terminal
|
||||
.draw(|f| {
|
||||
@@ -1320,10 +1334,7 @@ mod tests {
|
||||
compact
|
||||
}
|
||||
} else {
|
||||
Some(context_window_line(
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
))
|
||||
Some(context_line.clone())
|
||||
};
|
||||
let right_width = right_line
|
||||
.as_ref()
|
||||
@@ -1418,20 +1429,49 @@ mod tests {
|
||||
props: &FooterProps,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
) {
|
||||
let height = footer_height(props).max(1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator);
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
snapshot_footer_with_mode_indicator_and_context(
|
||||
name,
|
||||
width,
|
||||
props,
|
||||
collaboration_mode_indicator,
|
||||
context_window_line(/*percent*/ None, /*used_tokens*/ None),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_footer_with_mode_indicator(
|
||||
fn snapshot_footer_with_mode_indicator_and_context(
|
||||
name: &str,
|
||||
width: u16,
|
||||
props: &FooterProps,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
context_line: Line<'static>,
|
||||
) {
|
||||
let height = footer_height(props).max(1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
draw_footer_frame(
|
||||
&mut terminal,
|
||||
height,
|
||||
props,
|
||||
collaboration_mode_indicator,
|
||||
context_line,
|
||||
);
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
fn render_footer_with_mode_indicator_and_context(
|
||||
width: u16,
|
||||
props: &FooterProps,
|
||||
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
|
||||
context_line: Line<'static>,
|
||||
) -> String {
|
||||
let height = footer_height(props).max(1);
|
||||
let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal");
|
||||
draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator);
|
||||
draw_footer_frame(
|
||||
&mut terminal,
|
||||
height,
|
||||
props,
|
||||
collaboration_mode_indicator,
|
||||
context_line,
|
||||
);
|
||||
terminal.backend().vt100().screen().contents()
|
||||
}
|
||||
|
||||
@@ -1447,8 +1487,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1466,8 +1504,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints {
|
||||
@@ -1488,8 +1524,6 @@ mod tests {
|
||||
collaboration_modes_enabled: true,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1507,8 +1541,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1526,8 +1558,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1545,8 +1575,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1564,8 +1592,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1573,7 +1599,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
snapshot_footer_with_context(
|
||||
"footer_shortcuts_context_running",
|
||||
FooterProps {
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
@@ -1583,16 +1609,16 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(72),
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
Some(72),
|
||||
/*used_tokens*/ None,
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
snapshot_footer_with_context(
|
||||
"footer_context_tokens_used",
|
||||
FooterProps {
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
@@ -1602,13 +1628,13 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: Some(123_456),
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
},
|
||||
/*percent*/ None,
|
||||
Some(123_456),
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
@@ -1621,8 +1647,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1638,8 +1662,6 @@ mod tests {
|
||||
collaboration_modes_enabled: true,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1668,8 +1690,6 @@ mod tests {
|
||||
collaboration_modes_enabled: true,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1691,8 +1711,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1709,8 +1727,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1727,8 +1743,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1745,19 +1759,18 @@ mod tests {
|
||||
collaboration_modes_enabled: true,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(50),
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None, // command timed out / empty
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
snapshot_footer_with_mode_indicator(
|
||||
snapshot_footer_with_mode_indicator_and_context(
|
||||
"footer_status_line_enabled_mode_right",
|
||||
/*width*/ 120,
|
||||
&props,
|
||||
Some(CollaborationModeIndicator::Plan),
|
||||
context_window_line(Some(50), /*used_tokens*/ None),
|
||||
);
|
||||
|
||||
let props = FooterProps {
|
||||
@@ -1768,19 +1781,18 @@ mod tests {
|
||||
collaboration_modes_enabled: true,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(50),
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
snapshot_footer_with_mode_indicator(
|
||||
snapshot_footer_with_mode_indicator_and_context(
|
||||
"footer_status_line_disabled_context_right",
|
||||
/*width*/ 120,
|
||||
&props,
|
||||
Some(CollaborationModeIndicator::Plan),
|
||||
context_window_line(Some(50), /*used_tokens*/ None),
|
||||
);
|
||||
|
||||
let props = FooterProps {
|
||||
@@ -1791,8 +1803,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(50),
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1800,11 +1810,12 @@ mod tests {
|
||||
};
|
||||
|
||||
// has status line and no collaboration mode
|
||||
snapshot_footer_with_mode_indicator(
|
||||
snapshot_footer_with_mode_indicator_and_context(
|
||||
"footer_status_line_enabled_no_mode_right",
|
||||
/*width*/ 120,
|
||||
&props,
|
||||
/*collaboration_mode_indicator*/ None,
|
||||
context_window_line(Some(50), /*used_tokens*/ None),
|
||||
);
|
||||
|
||||
let props = FooterProps {
|
||||
@@ -1815,8 +1826,6 @@ mod tests {
|
||||
collaboration_modes_enabled: true,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(50),
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from(
|
||||
"Status line content that should truncate before the mode indicator".to_string(),
|
||||
)),
|
||||
@@ -1825,11 +1834,12 @@ mod tests {
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
snapshot_footer_with_mode_indicator(
|
||||
snapshot_footer_with_mode_indicator_and_context(
|
||||
"footer_status_line_truncated_with_gap",
|
||||
/*width*/ 40,
|
||||
&props,
|
||||
Some(CollaborationModeIndicator::Plan),
|
||||
context_window_line(Some(50), /*used_tokens*/ None),
|
||||
);
|
||||
|
||||
let props = FooterProps {
|
||||
@@ -1840,8 +1850,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: None,
|
||||
status_line_enabled: false,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1858,8 +1866,6 @@ mod tests {
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
key_hints: FooterKeyHints::default_bindings(),
|
||||
@@ -1879,8 +1885,6 @@ mod tests {
|
||||
collaboration_modes_enabled: true,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(50),
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from(
|
||||
"Status line content that is definitely too long to fit alongside the mode label"
|
||||
.to_string(),
|
||||
@@ -1890,10 +1894,11 @@ mod tests {
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
let screen = render_footer_with_mode_indicator(
|
||||
let screen = render_footer_with_mode_indicator_and_context(
|
||||
/*width*/ 80,
|
||||
&props,
|
||||
Some(CollaborationModeIndicator::Plan),
|
||||
context_window_line(Some(50), /*used_tokens*/ None),
|
||||
);
|
||||
let collapsed = screen.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(
|
||||
|
||||
@@ -1475,7 +1475,7 @@ mod tests {
|
||||
fn preserve_side_content_bg_keeps_rendered_background_colors() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
@@ -1494,7 +1494,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
let area = Rect::new(0, 0, 120, 35);
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -1852,7 +1851,7 @@ mod tests {
|
||||
fn enter_with_no_matches_triggers_cancel_callback() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ListSelectionView::new(
|
||||
let mut view = new_view(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Read Only".to_string(),
|
||||
@@ -1866,7 +1865,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
view.set_search_query("no-matches".to_string());
|
||||
|
||||
@@ -1884,7 +1882,7 @@ mod tests {
|
||||
fn move_down_without_selection_change_does_not_fire_callback() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ListSelectionView::new(
|
||||
let mut view = new_view(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Only choice".to_string(),
|
||||
@@ -1897,7 +1895,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
@@ -2357,7 +2354,7 @@ mod tests {
|
||||
fn side_layout_width_half_uses_exact_split() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
@@ -2373,7 +2370,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let content_width: u16 = 120;
|
||||
@@ -2385,7 +2381,7 @@ mod tests {
|
||||
fn side_layout_width_half_falls_back_when_list_would_be_too_narrow() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
@@ -2401,7 +2397,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
assert_eq!(view.side_layout_width(/*content_width*/ 80), None);
|
||||
@@ -2411,7 +2406,7 @@ mod tests {
|
||||
fn stacked_side_content_is_used_when_side_by_side_does_not_fit() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
@@ -2432,7 +2427,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&view, /*width*/ 70);
|
||||
@@ -2450,7 +2444,7 @@ mod tests {
|
||||
fn side_content_clearing_resets_symbols_and_style() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
@@ -2467,7 +2461,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
@@ -2510,7 +2503,7 @@ mod tests {
|
||||
fn side_content_clearing_handles_non_zero_buffer_origin() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
let view = new_view(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
@@ -2527,7 +2520,6 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
crate::keymap::RuntimeKeymap::defaults().list,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
|
||||
@@ -420,6 +420,17 @@ 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(crate) fn toggle_vim_enabled(&mut self) -> bool {
|
||||
let enabled = self.composer.toggle_vim_enabled();
|
||||
self.request_redraw();
|
||||
enabled
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
@@ -590,6 +601,7 @@ impl BottomPane {
|
||||
&& self.is_task_running
|
||||
&& !is_agent_command
|
||||
&& !self.composer.popup_active()
|
||||
&& !self.composer_should_handle_vim_insert_escape(key_event)
|
||||
&& let Some(status) = &self.status
|
||||
{
|
||||
// Send Op::Interrupt
|
||||
@@ -1125,6 +1137,15 @@ impl BottomPane {
|
||||
self.composer.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn composer_is_vim_enabled(&self) -> bool {
|
||||
self.composer.is_vim_enabled()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool {
|
||||
self.composer.should_handle_vim_insert_escape(key_event)
|
||||
}
|
||||
|
||||
pub(crate) fn is_task_running(&self) -> bool {
|
||||
self.is_task_running
|
||||
}
|
||||
@@ -1563,6 +1584,10 @@ impl Renderable for BottomPane {
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.as_renderable().cursor_pos(area)
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
|
||||
self.as_renderable().cursor_style(area)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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;
|
||||
@@ -95,7 +97,13 @@ pub(crate) struct TextArea {
|
||||
elements: Vec<TextElement>,
|
||||
next_element_id: u64,
|
||||
kill_buffer: String,
|
||||
kill_buffer_kind: KillBufferKind,
|
||||
vim_enabled: bool,
|
||||
vim_mode: VimMode,
|
||||
vim_operator: Option<VimOperator>,
|
||||
editor_keymap: EditorKeymap,
|
||||
vim_normal_keymap: VimNormalKeymap,
|
||||
vim_operator_keymap: VimOperatorKeymap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -110,8 +118,55 @@ pub(crate) struct TextAreaState {
|
||||
scroll: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimMode {
|
||||
/// Normal mode routes printable keys to movement, operators, and mode transitions.
|
||||
Normal,
|
||||
/// Insert mode routes input through the regular editor keymap until Escape is pressed.
|
||||
Insert,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum KillBufferKind {
|
||||
/// Characterwise kills and yanks paste at the cursor.
|
||||
Characterwise,
|
||||
/// Linewise kills and yanks paste as whole lines below the cursor line.
|
||||
Linewise,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimOperator {
|
||||
/// Delete the range selected by the next motion or repeated operator key.
|
||||
Delete,
|
||||
/// Copy the range selected by the next motion or repeated operator key.
|
||||
Yank,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimMotion {
|
||||
/// Move one atomic boundary to the left.
|
||||
Left,
|
||||
/// Move one atomic boundary to the right.
|
||||
Right,
|
||||
/// Move one visual row up, preserving preferred display column.
|
||||
Up,
|
||||
/// Move one visual row down, preserving preferred display column.
|
||||
Down,
|
||||
/// Move to the start of the next word-like run.
|
||||
WordForward,
|
||||
/// Move to the start of the previous word-like run.
|
||||
WordBackward,
|
||||
/// Move to the end of the current or next word-like run.
|
||||
WordEnd,
|
||||
/// Move to the start of the current line.
|
||||
LineStart,
|
||||
/// Move to the end of the current line.
|
||||
LineEnd,
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub fn new() -> Self {
|
||||
let defaults = RuntimeKeymap::defaults();
|
||||
Self {
|
||||
text: String::new(),
|
||||
cursor_pos: 0,
|
||||
@@ -120,18 +175,26 @@ impl TextArea {
|
||||
elements: Vec::new(),
|
||||
next_element_id: 1,
|
||||
kill_buffer: String::new(),
|
||||
editor_keymap: RuntimeKeymap::defaults().editor,
|
||||
kill_buffer_kind: KillBufferKind::Characterwise,
|
||||
vim_enabled: false,
|
||||
vim_mode: VimMode::Insert,
|
||||
vim_operator: None,
|
||||
editor_keymap: defaults.editor,
|
||||
vim_normal_keymap: defaults.vim_normal,
|
||||
vim_operator_keymap: defaults.vim_operator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the editor keymap used by subsequent text-editing input.
|
||||
/// Replace the editor and Vim keymaps used by subsequent text-editing input.
|
||||
///
|
||||
/// This method intentionally swaps only the keymap cache. It does not
|
||||
/// reinterpret pending input, move the cursor, or mutate the kill buffer, so
|
||||
/// callers can safely apply a live config update while preserving the
|
||||
/// current draft exactly as typed.
|
||||
pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) {
|
||||
self.editor_keymap = keymap.clone();
|
||||
/// This method intentionally swaps only the keymap caches. It does not
|
||||
/// reinterpret pending input, change Vim mode, move the cursor, or mutate
|
||||
/// the kill buffer, so callers can safely apply a live config update while
|
||||
/// preserving the current draft exactly as typed.
|
||||
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.
|
||||
@@ -185,6 +248,121 @@ impl TextArea {
|
||||
self.preferred_col = None;
|
||||
}
|
||||
|
||||
/// Enable or disable modal Vim editing for the textarea.
|
||||
///
|
||||
/// Enabling always enters normal mode and disabling always returns to
|
||||
/// insert semantics. Pending operators are cleared in both directions so a
|
||||
/// toggle cannot leave the next keypress interpreted as the second half of
|
||||
/// an old `d` or `y` command.
|
||||
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
|
||||
self.vim_enabled = enabled;
|
||||
self.vim_operator = None;
|
||||
self.vim_mode = if enabled {
|
||||
VimMode::Normal
|
||||
} else {
|
||||
VimMode::Insert
|
||||
};
|
||||
}
|
||||
|
||||
/// Return whether modal Vim editing is currently enabled.
|
||||
pub(crate) fn is_vim_enabled(&self) -> bool {
|
||||
self.vim_enabled
|
||||
}
|
||||
|
||||
/// Return whether Vim mode is enabled and currently waiting in normal mode.
|
||||
///
|
||||
/// Composer-level handlers use this to decide whether Up/Down should be
|
||||
/// offered to history navigation only after normal-mode movement reaches a
|
||||
/// text boundary.
|
||||
pub(crate) fn is_vim_normal_mode(&self) -> bool {
|
||||
self.vim_enabled && self.vim_mode == VimMode::Normal
|
||||
}
|
||||
|
||||
/// Return the cursor position that represents the last editable item in Vim normal mode.
|
||||
pub(crate) fn vim_normal_end_cursor(&self) -> usize {
|
||||
if self.text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.prev_atomic_boundary(self.text.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether a Vim operator is waiting for a motion.
|
||||
///
|
||||
/// This is observable so the composer can avoid stealing the second key of
|
||||
/// `d{motion}` or `y{motion}` for higher-level shortcuts.
|
||||
pub(crate) fn is_vim_operator_pending(&self) -> bool {
|
||||
self.vim_operator.is_some()
|
||||
}
|
||||
|
||||
/// Enter Vim insert mode if modal editing is enabled.
|
||||
///
|
||||
/// Calling this while Vim is disabled is a no-op, which lets parent
|
||||
/// workflows reset mode after submissions without first branching on the
|
||||
/// current keymap state.
|
||||
pub(crate) fn enter_vim_insert_mode(&mut self) {
|
||||
if self.vim_enabled {
|
||||
self.vim_mode = VimMode::Insert;
|
||||
self.vim_operator = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter Vim normal mode if modal editing is enabled.
|
||||
///
|
||||
/// This clears any pending operator and preferred vertical column. The
|
||||
/// latter matches normal Vim navigation expectations after leaving insert
|
||||
/// mode; preserving the old column would make the next `j` or `k` jump to a
|
||||
/// stale visual target.
|
||||
pub(crate) fn enter_vim_normal_mode(&mut self) {
|
||||
if self.vim_enabled {
|
||||
self.vim_mode = VimMode::Normal;
|
||||
self.vim_operator = None;
|
||||
self.preferred_col = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether rapid plain-key bursts should be treated as paste input.
|
||||
///
|
||||
/// Paste burst detection is disabled in Vim normal mode so a fast sequence
|
||||
/// like `dd` or `yw` remains command input instead of being converted into
|
||||
/// literal text.
|
||||
pub(crate) fn allows_paste_burst(&self) -> bool {
|
||||
!self.vim_enabled || self.vim_mode == VimMode::Insert
|
||||
}
|
||||
|
||||
/// Return whether rendering should use the insert-mode cursor style.
|
||||
pub(crate) fn uses_vim_insert_cursor(&self) -> bool {
|
||||
self.vim_enabled && self.vim_mode == VimMode::Insert
|
||||
}
|
||||
|
||||
/// Return whether Escape should be intercepted before composer-level routing.
|
||||
///
|
||||
/// In Vim insert mode, Escape is an editing transition rather than a popup
|
||||
/// cancel/backtrack shortcut. Letting the composer handle it first would
|
||||
/// close UI surfaces while leaving the textarea in insert mode.
|
||||
pub(crate) fn should_handle_vim_insert_escape(&self, event: KeyEvent) -> bool {
|
||||
self.vim_enabled
|
||||
&& self.vim_mode == VimMode::Insert
|
||||
&& event.code == KeyCode::Esc
|
||||
&& event.modifiers == KeyModifiers::NONE
|
||||
&& matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
}
|
||||
|
||||
/// Return the footer label for the active Vim mode.
|
||||
///
|
||||
/// `None` means Vim editing is disabled, so callers should omit the mode
|
||||
/// indicator rather than rendering an insert-mode label for normal
|
||||
/// non-modal editing.
|
||||
pub(crate) fn vim_mode_label(&self) -> Option<&'static str> {
|
||||
if !self.vim_enabled {
|
||||
return None;
|
||||
}
|
||||
Some(match self.vim_mode {
|
||||
VimMode::Normal => "Normal",
|
||||
VimMode::Insert => "Insert",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
@@ -318,6 +496,15 @@ impl TextArea {
|
||||
self.beginning_of_line(self.cursor_pos)
|
||||
}
|
||||
|
||||
fn first_non_blank_of_current_line(&self) -> usize {
|
||||
let bol = self.beginning_of_current_line();
|
||||
let eol = self.end_of_current_line();
|
||||
self.text[bol..eol]
|
||||
.char_indices()
|
||||
.find_map(|(offset, ch)| (!ch.is_whitespace()).then_some(bol + offset))
|
||||
.unwrap_or(eol)
|
||||
}
|
||||
|
||||
fn end_of_line(&self, pos: usize) -> usize {
|
||||
self.text[pos..]
|
||||
.find('\n')
|
||||
@@ -334,8 +521,12 @@ impl TextArea {
|
||||
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
let keymap = self.editor_keymap.clone();
|
||||
self.input_with_keymap(event, &keymap);
|
||||
if self.vim_enabled {
|
||||
self.handle_vim_input(event);
|
||||
} else {
|
||||
let keymap = self.editor_keymap.clone();
|
||||
self.input_with_keymap(event, &keymap);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
|
||||
@@ -447,10 +638,285 @@ impl TextArea {
|
||||
return;
|
||||
}
|
||||
self.insert_str(&c.to_string());
|
||||
}
|
||||
|
||||
tracing::debug!("Unhandled key event in TextArea: {:?}", event);
|
||||
}
|
||||
|
||||
fn handle_vim_input(&mut self, event: KeyEvent) {
|
||||
match self.vim_mode {
|
||||
VimMode::Insert => self.handle_vim_insert(event),
|
||||
VimMode::Normal => self.handle_vim_normal(event),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vim_insert(&mut self, event: KeyEvent) {
|
||||
if matches!(event.code, KeyCode::Esc) {
|
||||
let bol = self.beginning_of_current_line();
|
||||
if self.cursor_pos > bol {
|
||||
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos).max(bol);
|
||||
}
|
||||
self.enter_vim_normal_mode();
|
||||
return;
|
||||
}
|
||||
let keymap = self.editor_keymap.clone();
|
||||
self.input_with_keymap(event, &keymap);
|
||||
}
|
||||
|
||||
fn handle_vim_normal(&mut self, event: KeyEvent) {
|
||||
if let Some(op) = self.vim_operator.take() {
|
||||
self.handle_vim_operator(op, event);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = event;
|
||||
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.first_non_blank_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");
|
||||
let cursor = if eol < self.text.len() {
|
||||
insert_at
|
||||
} else {
|
||||
insert_at + 1
|
||||
};
|
||||
self.set_cursor(cursor);
|
||||
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.vim_word_end_cursor());
|
||||
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.vim_line_end_cursor());
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.delete_char.is_pressed(event) {
|
||||
self.delete_forward_kill(/*n*/ 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 {
|
||||
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> {
|
||||
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) {
|
||||
let Some(range) = self.range_for_motion(motion) else {
|
||||
return;
|
||||
};
|
||||
match op {
|
||||
VimOperator::Delete => self.kill_range(range),
|
||||
VimOperator::Yank => self.yank_range(range),
|
||||
}
|
||||
}
|
||||
|
||||
fn range_for_motion(&mut self, motion: VimMotion) -> Option<Range<usize>> {
|
||||
if matches!(motion, VimMotion::Up | VimMotion::Down) {
|
||||
return self.linewise_range_for_vertical_motion(motion);
|
||||
}
|
||||
let start = self.cursor_pos;
|
||||
let target = self.target_for_motion(motion);
|
||||
if start == target {
|
||||
return None;
|
||||
}
|
||||
let (range_start, range_end) = if target < start {
|
||||
(target, start)
|
||||
} else {
|
||||
(start, target)
|
||||
};
|
||||
Some(range_start..range_end)
|
||||
}
|
||||
|
||||
fn linewise_range_for_vertical_motion(&self, motion: VimMotion) -> Option<Range<usize>> {
|
||||
let current = self.current_line_range_with_newline();
|
||||
let range = match motion {
|
||||
VimMotion::Up => {
|
||||
let start = if current.start == 0 {
|
||||
current.start
|
||||
} else {
|
||||
self.beginning_of_line(current.start.saturating_sub(1))
|
||||
};
|
||||
start..current.end
|
||||
}
|
||||
VimMotion::Down => {
|
||||
let end = if current.end >= self.text.len() {
|
||||
current.end
|
||||
} else {
|
||||
let next_eol = self.end_of_line(current.end);
|
||||
if next_eol < self.text.len() {
|
||||
next_eol + 1
|
||||
} else {
|
||||
next_eol
|
||||
}
|
||||
};
|
||||
current.start..end
|
||||
}
|
||||
VimMotion::Left
|
||||
| VimMotion::Right
|
||||
| VimMotion::WordForward
|
||||
| VimMotion::WordBackward
|
||||
| VimMotion::WordEnd
|
||||
| VimMotion::LineStart
|
||||
| VimMotion::LineEnd => return None,
|
||||
};
|
||||
(range.start < range.end).then_some(range)
|
||||
}
|
||||
|
||||
fn target_for_motion(&mut self, motion: VimMotion) -> usize {
|
||||
let original_cursor = self.cursor_pos;
|
||||
let original_preferred = self.preferred_col;
|
||||
match motion {
|
||||
VimMotion::Left => self.move_cursor_left(),
|
||||
VimMotion::Right => self.move_cursor_right(),
|
||||
VimMotion::Up => self.move_cursor_up(),
|
||||
VimMotion::Down => self.move_cursor_down(),
|
||||
VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()),
|
||||
VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()),
|
||||
VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()),
|
||||
VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()),
|
||||
VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()),
|
||||
}
|
||||
let target = self.cursor_pos;
|
||||
self.cursor_pos = original_cursor;
|
||||
self.preferred_col = original_preferred;
|
||||
target
|
||||
}
|
||||
|
||||
// ####### Input Functions #######
|
||||
@@ -482,6 +948,20 @@ impl TextArea {
|
||||
self.replace_range(self.cursor_pos..target, "");
|
||||
}
|
||||
|
||||
pub fn delete_forward_kill(&mut self, n: usize) {
|
||||
if n == 0 || self.cursor_pos >= self.text.len() {
|
||||
return;
|
||||
}
|
||||
let mut target = self.cursor_pos;
|
||||
for _ in 0..n {
|
||||
target = self.next_atomic_boundary(target);
|
||||
if target >= self.text.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.kill_range(self.cursor_pos..target);
|
||||
}
|
||||
|
||||
pub fn delete_backward_word(&mut self) {
|
||||
let start = self.beginning_of_previous_word();
|
||||
self.kill_range(start..self.cursor_pos);
|
||||
@@ -549,6 +1029,14 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn kill_range(&mut self, range: Range<usize>) {
|
||||
self.kill_range_with_kind(range, KillBufferKind::Characterwise);
|
||||
}
|
||||
|
||||
fn kill_line_range(&mut self, range: Range<usize>) {
|
||||
self.kill_range_with_kind(range, KillBufferKind::Linewise);
|
||||
}
|
||||
|
||||
fn kill_range_with_kind(&mut self, range: Range<usize>, kind: KillBufferKind) {
|
||||
let range = self.expand_range_to_element_boundaries(range);
|
||||
if range.start >= range.end {
|
||||
return;
|
||||
@@ -559,10 +1047,87 @@ impl TextArea {
|
||||
return;
|
||||
}
|
||||
|
||||
self.kill_buffer = removed;
|
||||
self.store_kill_buffer(removed, kind);
|
||||
self.replace_range_raw(range, "");
|
||||
}
|
||||
|
||||
fn yank_range(&mut self, range: Range<usize>) {
|
||||
self.yank_range_with_kind(range, KillBufferKind::Characterwise);
|
||||
}
|
||||
|
||||
fn yank_line_range(&mut self, range: Range<usize>) {
|
||||
self.yank_range_with_kind(range, KillBufferKind::Linewise);
|
||||
}
|
||||
|
||||
fn yank_range_with_kind(&mut self, range: Range<usize>, kind: KillBufferKind) {
|
||||
let range = self.expand_range_to_element_boundaries(range);
|
||||
if range.start >= range.end {
|
||||
return;
|
||||
}
|
||||
let removed = self.text[range].to_string();
|
||||
if removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.store_kill_buffer(removed, kind);
|
||||
}
|
||||
|
||||
fn store_kill_buffer(&mut self, text: String, kind: KillBufferKind) {
|
||||
self.kill_buffer = text;
|
||||
self.kill_buffer_kind = kind;
|
||||
}
|
||||
|
||||
fn paste_after_cursor(&mut self) {
|
||||
if self.kill_buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.kill_buffer_kind == KillBufferKind::Linewise {
|
||||
self.paste_line_after_current_line();
|
||||
return;
|
||||
}
|
||||
let insert_at = self.next_atomic_boundary(self.cursor_pos);
|
||||
self.set_cursor(insert_at);
|
||||
let text = self.kill_buffer.clone();
|
||||
self.insert_str(&text);
|
||||
}
|
||||
|
||||
fn paste_line_after_current_line(&mut self) {
|
||||
let eol = self.end_of_current_line();
|
||||
let insert_at = if eol < self.text.len() { eol + 1 } else { eol };
|
||||
let cursor = if eol < self.text.len() {
|
||||
insert_at
|
||||
} else {
|
||||
insert_at + 1
|
||||
};
|
||||
let text = if eol < self.text.len() {
|
||||
if self.kill_buffer.ends_with('\n') {
|
||||
self.kill_buffer.clone()
|
||||
} else {
|
||||
format!("{}\n", self.kill_buffer)
|
||||
}
|
||||
} else {
|
||||
format!("\n{}", self.kill_buffer.trim_end_matches('\n'))
|
||||
};
|
||||
self.insert_str_at(insert_at, &text);
|
||||
self.set_cursor(cursor.min(self.text.len()));
|
||||
}
|
||||
|
||||
fn yank_current_line(&mut self) {
|
||||
let range = self.current_line_range_with_newline();
|
||||
self.yank_line_range(range);
|
||||
}
|
||||
|
||||
fn delete_current_line(&mut self) {
|
||||
let range = self.current_line_range_with_newline();
|
||||
self.kill_line_range(range);
|
||||
}
|
||||
|
||||
fn current_line_range_with_newline(&self) -> Range<usize> {
|
||||
let bol = self.beginning_of_current_line();
|
||||
let eol = self.end_of_current_line();
|
||||
let end = if eol < self.text.len() { eol + 1 } else { eol };
|
||||
bol..end
|
||||
}
|
||||
|
||||
/// Move the cursor left by a single grapheme cluster.
|
||||
pub fn move_cursor_left(&mut self) {
|
||||
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
|
||||
@@ -1189,6 +1754,44 @@ impl TextArea {
|
||||
self.adjust_pos_out_of_elements(end, /*prefer_start*/ false)
|
||||
}
|
||||
|
||||
fn vim_word_end_cursor(&self) -> usize {
|
||||
let end = self.end_of_next_word();
|
||||
if end > self.cursor_pos {
|
||||
self.prev_atomic_boundary(end)
|
||||
} else {
|
||||
end
|
||||
}
|
||||
}
|
||||
|
||||
fn vim_line_end_cursor(&self) -> usize {
|
||||
let bol = self.beginning_of_current_line();
|
||||
let eol = self.end_of_current_line();
|
||||
if eol > bol {
|
||||
self.prev_atomic_boundary(eol).max(bol)
|
||||
} else {
|
||||
eol
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn beginning_of_next_word(&self) -> usize {
|
||||
let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace())
|
||||
else {
|
||||
return self.text.len();
|
||||
};
|
||||
let word_start = self.cursor_pos + first_non_ws;
|
||||
if word_start != self.cursor_pos {
|
||||
return self.adjust_pos_out_of_elements(word_start, /*prefer_start*/ true);
|
||||
}
|
||||
let end = self.end_of_next_word();
|
||||
if end >= self.text.len() {
|
||||
return self.text.len();
|
||||
}
|
||||
let Some(next_non_ws) = self.text[end..].find(|c: char| !c.is_whitespace()) else {
|
||||
return self.text.len();
|
||||
};
|
||||
self.adjust_pos_out_of_elements(end + next_non_ws, /*prefer_start*/ true)
|
||||
}
|
||||
|
||||
fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize {
|
||||
if let Some(idx) = self.find_element_containing(pos) {
|
||||
let e = &self.elements[idx];
|
||||
@@ -1408,6 +2011,7 @@ impl TextArea {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::key_hint;
|
||||
// crossterm types are intentionally not imported here to avoid unused warnings
|
||||
use pretty_assertions::assert_eq;
|
||||
use rand::prelude::*;
|
||||
@@ -1564,6 +2168,234 @@ mod tests {
|
||||
assert_eq!(t.cursor(), elem_start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_insert_and_escape() {
|
||||
let mut t = TextArea::new();
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "h");
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
assert_eq!(t.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_insert_key_enters_insert_mode() {
|
||||
let mut t = TextArea::new();
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "h");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_normal_arrow_keys_move_cursor() {
|
||||
let mut t = ta_with("ab\ncd");
|
||||
t.set_cursor(/*pos*/ 1);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||||
assert_eq!(t.cursor(), 2);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
assert_eq!(t.cursor(), 5);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
assert_eq!(t.cursor(), 4);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(t.cursor(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_escape_from_insert_at_start_does_not_underflow() {
|
||||
let mut t = TextArea::new();
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
assert_eq!(t.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_escape_from_insert_at_line_start_stays_on_line() {
|
||||
let mut t = ta_with("one\ntwo");
|
||||
t.set_cursor(/*pos*/ "one\n".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
assert_eq!(t.cursor(), "one\n".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_escape_moves_by_grapheme_boundary() {
|
||||
let mut t = ta_with("👍👍");
|
||||
t.set_cursor(t.text().len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
assert_eq!(t.cursor(), "👍".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_escape_respects_atomic_element_boundary() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str("a");
|
||||
t.insert_element("<element>");
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
assert_eq!(t.cursor(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_shift_i_enters_insert_at_first_non_blank_with_shift_only_binding() {
|
||||
let mut t = ta_with("hello\n world");
|
||||
t.vim_normal_keymap.insert_line_start = vec![key_hint::shift(KeyCode::Char('i'))];
|
||||
t.set_cursor(/*pos*/ "hello\n wor".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), "hello\n ".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_shift_a_enters_insert_at_line_end_with_shift_only_binding() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
t.vim_normal_keymap.append_line_end = vec![key_hint::shift(KeyCode::Char('a'))];
|
||||
t.set_cursor(/*pos*/ 8);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_shift_o_opens_line_above_with_shift_only_binding() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
t.vim_normal_keymap.open_line_above = vec![key_hint::shift(KeyCode::Char('o'))];
|
||||
t.set_cursor(/*pos*/ 8);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello\n\nworld");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_o_opens_line_below_on_inserted_line() {
|
||||
let mut t = ta_with("one\ntwo");
|
||||
t.set_cursor(/*pos*/ 1);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "one\n\ntwo");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), "one\n".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_delete_word() {
|
||||
let mut t = ta_with("hello world");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "world");
|
||||
assert_eq!(t.kill_buffer, "hello ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_operator_invalid_motion_is_consumed() {
|
||||
let mut t = ta_with("hello");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert!(t.is_vim_operator_pending());
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello");
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
assert_eq!(t.cursor(), 0);
|
||||
assert!(!t.is_vim_operator_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_e_lands_on_word_end_character() {
|
||||
let mut t = ta_with("abc");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.cursor(), 2);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "ab");
|
||||
assert_eq!(t.kill_buffer, "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_dollar_lands_on_line_end_character() {
|
||||
let mut t = ta_with("abc\n123");
|
||||
t.set_cursor(/*pos*/ 1);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.cursor(), 2);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "ab\n123");
|
||||
assert_eq!(t.kill_buffer, "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_linewise_yank_pastes_below_current_line() {
|
||||
let mut t = ta_with("abc\n123\nxyz");
|
||||
t.set_cursor(/*pos*/ 1);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "abc\nabc\n123\nxyz");
|
||||
assert_eq!(t.cursor(), "abc\n".len());
|
||||
assert_eq!(t.kill_buffer, "abc\n");
|
||||
assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_backward_word_and_kill_line_variants() {
|
||||
// delete backward word at end removes the whole previous word
|
||||
|
||||
@@ -4964,6 +4964,9 @@ impl ChatWidget {
|
||||
if let Some(keymap) = runtime_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());
|
||||
@@ -5114,6 +5117,7 @@ impl ChatWidget {
|
||||
&& !self.pending_steers.is_empty()
|
||||
&& self.bottom_pane.is_task_running()
|
||||
&& self.bottom_pane.no_modal_or_popup_active()
|
||||
&& !self.should_handle_vim_insert_escape(key_event)
|
||||
{
|
||||
self.submit_pending_steers_after_interrupt = true;
|
||||
if !self.submit_op(AppCommand::interrupt()) {
|
||||
@@ -10265,6 +10269,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(), /*hint*/ None);
|
||||
}
|
||||
|
||||
pub(crate) fn submit_user_message_with_mode(
|
||||
&mut self,
|
||||
text: String,
|
||||
@@ -10306,6 +10320,11 @@ impl ChatWidget {
|
||||
self.bottom_pane.is_normal_backtrack_mode()
|
||||
}
|
||||
|
||||
pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool {
|
||||
self.bottom_pane
|
||||
.composer_should_handle_vim_insert_escape(key_event)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.bottom_pane.insert_str(text);
|
||||
}
|
||||
@@ -10840,6 +10859,10 @@ impl Renderable for ChatWidget {
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.as_renderable().cursor_pos(area)
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
|
||||
self.as_renderable().cursor_style(area)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -243,6 +243,9 @@ impl ChatWidget {
|
||||
SlashCommand::Permissions => {
|
||||
self.open_permissions_popup();
|
||||
}
|
||||
SlashCommand::Vim => {
|
||||
self.toggle_vim_mode_and_notify();
|
||||
}
|
||||
SlashCommand::Keymap => {
|
||||
self.open_keymap_picker();
|
||||
}
|
||||
@@ -846,6 +849,7 @@ impl ChatWidget {
|
||||
| SlashCommand::Plugins
|
||||
| SlashCommand::Rollout
|
||||
| SlashCommand::Copy
|
||||
| SlashCommand::Vim
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::TestApproval => QueueDrain::Continue,
|
||||
|
||||
@@ -895,6 +895,33 @@ async fn empty_enter_during_task_does_not_queue() {
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||||
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
chat.pending_steers.push_back(pending_steer("queued steer"));
|
||||
chat.toggle_vim_mode_and_notify();
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
|
||||
assert!(chat.should_handle_vim_insert_escape(esc));
|
||||
chat.handle_key_event(esc);
|
||||
|
||||
assert!(!chat.should_handle_vim_insert_escape(esc));
|
||||
assert_eq!(chat.pending_steers.len(), 1);
|
||||
assert!(!chat.submit_pending_steers_after_interrupt);
|
||||
assert!(op_rx.try_recv().is_err());
|
||||
|
||||
chat.handle_key_event(esc);
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Interrupt) => {}
|
||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||
}
|
||||
assert!(chat.submit_pending_steers_after_interrupt);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -1487,13 +1487,46 @@ 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(),
|
||||
crate::legacy_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.composer_is_empty());
|
||||
let mut chat = chat;
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "");
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -1512,16 +1545,14 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
|
||||
status_account_display: None,
|
||||
runtime_model_provider_base_url: None,
|
||||
initial_plan_type: None,
|
||||
model: Some(resolved_model.clone()),
|
||||
model: Some(resolved_model),
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
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]
|
||||
|
||||
@@ -25,6 +25,7 @@ use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::queue;
|
||||
use crossterm::style::Colors;
|
||||
use crossterm::style::Print;
|
||||
@@ -78,7 +79,6 @@ fn display_width(s: &str) -> usize {
|
||||
visible.width()
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash)]
|
||||
pub struct Frame<'a> {
|
||||
/// Where should the cursor be after drawing this frame?
|
||||
///
|
||||
@@ -86,6 +86,9 @@ pub struct Frame<'a> {
|
||||
/// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`.
|
||||
pub(crate) cursor_position: Option<Position>,
|
||||
|
||||
/// Visible cursor shape to apply after drawing this frame.
|
||||
cursor_style: SetCursorStyle,
|
||||
|
||||
/// The area of the viewport
|
||||
pub(crate) viewport_area: Rect,
|
||||
|
||||
@@ -128,6 +131,11 @@ impl Frame<'_> {
|
||||
self.cursor_position = Some(position.into());
|
||||
}
|
||||
|
||||
/// After drawing this frame, set the terminal's visible cursor style.
|
||||
pub fn set_cursor_style(&mut self, style: SetCursorStyle) {
|
||||
self.cursor_style = style;
|
||||
}
|
||||
|
||||
/// Gets the buffer that this `Frame` draws into as a mutable reference.
|
||||
pub fn buffer_mut(&mut self) -> &mut Buffer {
|
||||
self.buffer
|
||||
@@ -167,6 +175,10 @@ where
|
||||
#[allow(clippy::print_stderr)]
|
||||
fn drop(&mut self) {
|
||||
// Attempt to restore the cursor state
|
||||
if let Err(err) = self.reset_cursor_style() {
|
||||
eprintln!("Failed to reset the cursor style: {err}");
|
||||
}
|
||||
|
||||
if self.hidden_cursor
|
||||
&& let Err(err) = self.show_cursor()
|
||||
{
|
||||
@@ -205,6 +217,7 @@ where
|
||||
pub fn get_frame(&mut self) -> Frame<'_> {
|
||||
Frame {
|
||||
cursor_position: None,
|
||||
cursor_style: SetCursorStyle::DefaultUserShape,
|
||||
viewport_area: self.viewport_area,
|
||||
buffer: self.current_buffer_mut(),
|
||||
}
|
||||
@@ -362,6 +375,7 @@ where
|
||||
// stdout first. But we also can't keep the frame around, since it holds a &mut to
|
||||
// Buffer. Thus, we're taking the important data out of the Frame and dropping it.
|
||||
let cursor_position = frame.cursor_position;
|
||||
let cursor_style = frame.cursor_style;
|
||||
|
||||
// Draw to stdout
|
||||
self.flush()?;
|
||||
@@ -369,6 +383,7 @@ where
|
||||
match cursor_position {
|
||||
None => self.hide_cursor()?,
|
||||
Some(position) => {
|
||||
self.set_cursor_style(cursor_style)?;
|
||||
self.show_cursor()?;
|
||||
self.set_cursor_position(position)?;
|
||||
}
|
||||
@@ -395,6 +410,16 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the visible terminal cursor style.
|
||||
pub fn set_cursor_style(&mut self, style: SetCursorStyle) -> io::Result<()> {
|
||||
queue!(self.backend, style)
|
||||
}
|
||||
|
||||
/// Restores the user-configured terminal cursor style.
|
||||
pub fn reset_cursor_style(&mut self) -> io::Result<()> {
|
||||
self.set_cursor_style(SetCursorStyle::DefaultUserShape)
|
||||
}
|
||||
|
||||
/// Gets the current cursor position.
|
||||
///
|
||||
/// This is the position of the cursor after the last draw call.
|
||||
@@ -712,9 +737,110 @@ impl ModifierDiff {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::backend::WindowSize;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
|
||||
struct CaptureBackend {
|
||||
output: Vec<u8>,
|
||||
size: Size,
|
||||
cursor: Position,
|
||||
}
|
||||
|
||||
impl CaptureBackend {
|
||||
fn new(width: u16, height: u16) -> Self {
|
||||
Self {
|
||||
output: Vec::new(),
|
||||
size: Size { width, height },
|
||||
cursor: Position { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8_lossy(&self.output).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for CaptureBackend {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.output.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for CaptureBackend {
|
||||
fn draw<'a, I>(&mut self, _content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hide_cursor(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_cursor(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_cursor_position(&mut self) -> io::Result<Position> {
|
||||
Ok(self.cursor)
|
||||
}
|
||||
|
||||
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
|
||||
self.cursor = position.into();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_region(&mut self, _clear_type: ClearType) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_lines(&mut self, _line_count: u16) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scroll_region_up(
|
||||
&mut self,
|
||||
_region: std::ops::Range<u16>,
|
||||
_scroll_by: u16,
|
||||
) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scroll_region_down(
|
||||
&mut self,
|
||||
_region: std::ops::Range<u16>,
|
||||
_scroll_by: u16,
|
||||
) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn size(&self) -> io::Result<Size> {
|
||||
Ok(self.size)
|
||||
}
|
||||
|
||||
fn window_size(&mut self) -> io::Result<WindowSize> {
|
||||
Ok(WindowSize {
|
||||
columns_rows: self.size,
|
||||
pixels: self.size,
|
||||
})
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
|
||||
let area = Rect::new(0, 0, 3, 2);
|
||||
@@ -760,4 +886,48 @@ mod tests {
|
||||
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_draw_applies_requested_cursor_style() {
|
||||
let mut output = Vec::new();
|
||||
let mut terminal =
|
||||
Terminal::with_options(CaptureBackend::new(/*width*/ 2, /*height*/ 1))
|
||||
.expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 2, 1));
|
||||
|
||||
terminal
|
||||
.try_draw(|frame| {
|
||||
frame.set_cursor_style(SetCursorStyle::SteadyBar);
|
||||
frame.set_cursor_position((0, 0));
|
||||
io::Result::Ok(())
|
||||
})
|
||||
.expect("draw");
|
||||
|
||||
queue!(output, SetCursorStyle::SteadyBar).expect("queue style");
|
||||
let expected = String::from_utf8(output).expect("utf8");
|
||||
let actual = terminal.backend().output();
|
||||
assert!(
|
||||
actual.contains(&expected),
|
||||
"expected terminal output to contain cursor style {expected:?}, got {actual:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_cursor_style_emits_default_user_shape() {
|
||||
let mut output = Vec::new();
|
||||
let mut terminal =
|
||||
Terminal::with_options(CaptureBackend::new(/*width*/ 2, /*height*/ 1))
|
||||
.expect("terminal");
|
||||
|
||||
terminal.reset_cursor_style().expect("reset cursor style");
|
||||
ratatui::backend::Backend::flush(terminal.backend_mut()).expect("flush backend");
|
||||
|
||||
queue!(output, SetCursorStyle::DefaultUserShape).expect("queue style");
|
||||
let expected = String::from_utf8(output).expect("utf8");
|
||||
let actual = terminal.backend().output();
|
||||
assert!(
|
||||
actual.contains(&expected),
|
||||
"expected terminal output to contain cursor style reset {expected:?}, got {actual:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
//! Key binding primitives and input matching for the TUI.
|
||||
//!
|
||||
//! This module provides `KeyBinding`, the runtime representation of a single
|
||||
//! keybinding (key code + modifier set), along with matching logic that handles
|
||||
//! cross-terminal inconsistencies in how shifted letters are reported.
|
||||
//!
|
||||
//! It also supplies rendering helpers that convert bindings into styled
|
||||
//! `ratatui::text::Span` values for UI hint display.
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -17,10 +26,13 @@ const SHIFT_PREFIX: &str = "shift + ";
|
||||
|
||||
/// One concrete key event that can trigger a TUI action.
|
||||
///
|
||||
/// The binding stores the terminal key code plus the exact modifier set that
|
||||
/// must be present on an incoming press or repeat event. It does not model
|
||||
/// multi-key chords or partial matches; callers that need sequences must keep
|
||||
/// that state outside this type.
|
||||
/// Matching via `is_press` handles both exact equality and a shifted-letter
|
||||
/// compatibility fallback for terminals that report uppercase letters without
|
||||
/// the SHIFT modifier flag. This means a binding defined as `shift-a` will
|
||||
/// match a terminal event of either `Shift+a` or plain `A`.
|
||||
///
|
||||
/// This does not model multi-key chords or partial matches; callers that need
|
||||
/// sequences must keep that state outside this type.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) struct KeyBinding {
|
||||
key: KeyCode,
|
||||
@@ -205,10 +217,29 @@ mod tests {
|
||||
fn shifted_letter_binding_matches_uppercase_char_events() {
|
||||
let binding = shift(KeyCode::Char('a'));
|
||||
|
||||
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)));
|
||||
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)));
|
||||
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_letter_binding_preserves_other_modifiers_with_uppercase_compat() {
|
||||
let binding = KeyBinding::new(
|
||||
KeyCode::Char('i'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
);
|
||||
|
||||
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::CONTROL)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_letter_binding_does_not_match_plain_lowercase_or_other_uppercase() {
|
||||
let binding = shift(KeyCode::Char('o'));
|
||||
|
||||
assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)));
|
||||
assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_letter_binding_matches_c0_control_char_events() {
|
||||
let binding = ctrl(KeyCode::Char('p'));
|
||||
|
||||
+332
-1
@@ -44,6 +44,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,
|
||||
@@ -59,9 +61,16 @@ pub(crate) struct AppKeymap {
|
||||
pub(crate) copy: Vec<KeyBinding>,
|
||||
/// Clear the terminal UI.
|
||||
pub(crate) clear_terminal: Vec<KeyBinding>,
|
||||
/// Toggle Vim mode for the composer input.
|
||||
pub(crate) toggle_vim_mode: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
/// Main chat-surface keybindings.
|
||||
/// Chat-level keybindings evaluated at the app event layer.
|
||||
///
|
||||
/// These participate in the first app-scope conflict validation pass alongside
|
||||
/// `AppKeymap` actions because both are checked before input reaches the
|
||||
/// composer. Dispatch gating (empty-composer guard for backtrack) happens in
|
||||
/// handler code, not here.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChatKeymap {
|
||||
/// Decrease the active reasoning effort.
|
||||
@@ -72,6 +81,11 @@ pub(crate) struct ChatKeymap {
|
||||
pub(crate) edit_queued_message: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
/// Composer-level keybindings validated in the second app-scope conflict pass.
|
||||
///
|
||||
/// App-level handlers execute before the composer receives input, so any key
|
||||
/// bound here that also appears in `AppKeymap` would be silently intercepted.
|
||||
/// The conflict validator prevents this by checking app + composer uniqueness.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ComposerKeymap {
|
||||
/// Submit current draft.
|
||||
@@ -110,6 +124,62 @@ pub(crate) struct EditorKeymap {
|
||||
pub(crate) yank: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
/// Vim normal-mode keybindings for modal editing in the composer textarea.
|
||||
///
|
||||
/// Normal mode is the resting state when Vim is enabled. Pressing a movement
|
||||
/// or editing key here either moves the cursor, triggers an operator-pending
|
||||
/// state (via `start_delete_operator` / `start_yank_operator`), or transitions
|
||||
/// to insert mode. Default bindings include both `shift(letter)` and
|
||||
/// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to
|
||||
/// handle cross-terminal shift-reporting inconsistencies.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Vim operator-pending keybindings active after `d` or `y` in normal mode.
|
||||
///
|
||||
/// When an operator (`start_delete_operator` or `start_yank_operator`) is
|
||||
/// pressed, the next keypress is matched against this context to determine the
|
||||
/// motion range. Repeating the operator key (`dd`, `yy`) acts on the whole
|
||||
/// line. `Esc` cancels the pending operator and returns to normal mode.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
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 {
|
||||
@@ -294,6 +364,11 @@ impl RuntimeKeymap {
|
||||
&defaults.app.clear_terminal,
|
||||
"tui.keymap.global.clear_terminal",
|
||||
)?,
|
||||
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 {
|
||||
@@ -346,6 +421,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),
|
||||
@@ -382,6 +512,8 @@ impl RuntimeKeymap {
|
||||
chat,
|
||||
composer,
|
||||
editor,
|
||||
vim_normal,
|
||||
vim_operator,
|
||||
pager,
|
||||
list,
|
||||
approval,
|
||||
@@ -403,6 +535,7 @@ impl RuntimeKeymap {
|
||||
open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))],
|
||||
copy: default_bindings![ctrl(KeyCode::Char('o'))],
|
||||
clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))],
|
||||
toggle_vim_mode: default_bindings![],
|
||||
},
|
||||
chat: ChatKeymap {
|
||||
decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))],
|
||||
@@ -463,6 +596,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')), plain(KeyCode::Insert)],
|
||||
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')), plain(KeyCode::Left)],
|
||||
move_right: default_bindings![plain(KeyCode::Char('l')), plain(KeyCode::Right)],
|
||||
move_up: default_bindings![plain(KeyCode::Char('k')), plain(KeyCode::Up)],
|
||||
move_down: default_bindings![plain(KeyCode::Char('j')), plain(KeyCode::Down)],
|
||||
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'))],
|
||||
@@ -536,6 +725,7 @@ impl RuntimeKeymap {
|
||||
),
|
||||
("copy", self.app.copy.as_slice()),
|
||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
self.chat.decrease_reasoning_effort.as_slice(),
|
||||
@@ -575,6 +765,7 @@ impl RuntimeKeymap {
|
||||
),
|
||||
("copy", self.app.copy.as_slice()),
|
||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
self.chat.decrease_reasoning_effort.as_slice(),
|
||||
@@ -615,6 +806,7 @@ impl RuntimeKeymap {
|
||||
),
|
||||
("copy", self.app.copy.as_slice()),
|
||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
],
|
||||
[
|
||||
("list.move_up", self.list.move_up.as_slice()),
|
||||
@@ -662,6 +854,7 @@ impl RuntimeKeymap {
|
||||
self.chat.increase_reasoning_effort.as_slice(),
|
||||
),
|
||||
("composer.submit", self.composer.submit.as_slice()),
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
(
|
||||
"composer.history_search_previous",
|
||||
self.composer.history_search_previous.as_slice(),
|
||||
@@ -747,6 +940,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",
|
||||
[
|
||||
@@ -1318,6 +1608,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_normal_defaults_include_insert_and_arrow_aliases() {
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
|
||||
assert_eq!(
|
||||
runtime.vim_normal.enter_insert,
|
||||
vec![
|
||||
key_hint::plain(KeyCode::Char('i')),
|
||||
key_hint::plain(KeyCode::Insert)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.vim_normal.move_left,
|
||||
vec![
|
||||
key_hint::plain(KeyCode::Char('h')),
|
||||
key_hint::plain(KeyCode::Left)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.vim_normal.move_right,
|
||||
vec![
|
||||
key_hint::plain(KeyCode::Char('l')),
|
||||
key_hint::plain(KeyCode::Right)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.vim_normal.move_up,
|
||||
vec![
|
||||
key_hint::plain(KeyCode::Char('k')),
|
||||
key_hint::plain(KeyCode::Up)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.vim_normal.move_down,
|
||||
vec![
|
||||
key_hint::plain(KeyCode::Char('j')),
|
||||
key_hint::plain(KeyCode::Down)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_global_copy_binding_reports_global_path() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
//! Guided keymap remapping UI for `/keymap`.
|
||||
//!
|
||||
//! Pick an action, choose whether to set or remove its root-level custom
|
||||
//! binding, then validate and persist the resulting runtime keymap.
|
||||
//! This module owns the interactive editing flow that starts from a resolved
|
||||
//! [`RuntimeKeymap`] and produces a new root-level [`TuiKeymap`] override. The
|
||||
//! picker and action menus show users the currently active binding, which may
|
||||
//! come from defaults, global fallback, or explicit config, while writes always
|
||||
//! target the concrete `tui.keymap.<context>.<action>` slot selected by the
|
||||
//! user.
|
||||
//!
|
||||
//! The flow is intentionally split into three steps: choose an action, choose
|
||||
//! whether to replace/add/remove a binding, then capture exactly one terminal
|
||||
//! key event. Validation happens after capture by reusing runtime keymap
|
||||
//! resolution, so conflict rules stay centralized in `keymap.rs` instead of
|
||||
//! being duplicated in the UI.
|
||||
//!
|
||||
//! This module does not persist config files directly. It emits app events with
|
||||
//! the edited config so the app layer can decide how to save, reload, and
|
||||
//! surface errors.
|
||||
|
||||
mod actions;
|
||||
mod picker;
|
||||
@@ -47,14 +61,14 @@ pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-bin
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum KeymapEditOutcome {
|
||||
/// The edit produced a new config snapshot and user-facing status message.
|
||||
Updated {
|
||||
keymap_config: Box<TuiKeymap>,
|
||||
bindings: Vec<String>,
|
||||
message: String,
|
||||
},
|
||||
Unchanged {
|
||||
message: String,
|
||||
},
|
||||
/// The requested edit resolved to the same effective binding set.
|
||||
Unchanged { message: String },
|
||||
}
|
||||
|
||||
fn key_binding_span(binding: &str) -> ratatui::text::Span<'static> {
|
||||
@@ -109,6 +123,13 @@ fn action_menu_item(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the action-specific menu after a user chooses a shortcut row.
|
||||
///
|
||||
/// The menu is based on both active runtime bindings and root config state: the
|
||||
/// active bindings decide whether replace/add choices are available, while the
|
||||
/// config state decides whether "remove custom binding" can restore fallback
|
||||
/// behavior. Passing stale context/action strings yields a generic fallback
|
||||
/// menu rather than panicking, because selection views can outlive config reloads.
|
||||
pub(crate) fn build_keymap_action_menu_params(
|
||||
context: String,
|
||||
action: String,
|
||||
@@ -362,6 +383,12 @@ pub(crate) fn build_keymap_conflict_params(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the transient capture view for the selected keymap edit.
|
||||
///
|
||||
/// The view displays the current binding summary from the latest runtime map
|
||||
/// and then delegates the captured key back to the app event loop. Unknown
|
||||
/// actions are rendered as unbound so the eventual edit path can report the
|
||||
/// stale selection with a precise error.
|
||||
pub(crate) fn build_keymap_capture_view(
|
||||
context: String,
|
||||
action: String,
|
||||
@@ -393,6 +420,14 @@ fn keymap_with_replacement(
|
||||
keymap_with_bindings(keymap, context, action, &[key.to_string()])
|
||||
}
|
||||
|
||||
/// Apply a captured key to one action and return the edited root config.
|
||||
///
|
||||
/// The current effective bindings come from `runtime_keymap`, so adding an
|
||||
/// alternate to a default-only action first materializes those defaults into
|
||||
/// root config before appending the captured key. Replacing one binding guards
|
||||
/// against stale menus by requiring the selected `old_key` to still be active;
|
||||
/// otherwise a user could overwrite a binding that changed after the menu was
|
||||
/// opened.
|
||||
pub(crate) fn keymap_with_edit(
|
||||
keymap: &TuiKeymap,
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
@@ -481,6 +516,12 @@ fn keymap_with_bindings(
|
||||
Ok(keymap)
|
||||
}
|
||||
|
||||
/// Return the active config key specs for one runtime action.
|
||||
///
|
||||
/// This converts resolved [`crate::key_hint::KeyBinding`] values back into
|
||||
/// canonical config strings for display and for edit operations that need to
|
||||
/// preserve existing bindings. Callers should treat errors as stale UI state,
|
||||
/// because valid menu entries should always point at known actions.
|
||||
pub(crate) fn active_binding_specs(
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
context: &str,
|
||||
@@ -504,6 +545,11 @@ fn dedup_bindings(bindings: Vec<String>) -> Vec<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove the root-level custom binding for one action.
|
||||
///
|
||||
/// Clearing the slot with `None` is different from setting an empty binding
|
||||
/// list: `None` restores default/global fallback behavior, while an empty list
|
||||
/// explicitly unbinds the action in runtime resolution.
|
||||
pub(crate) fn keymap_without_custom_binding(
|
||||
keymap: &TuiKeymap,
|
||||
context: &str,
|
||||
@@ -525,6 +571,12 @@ fn has_custom_binding(keymap: &TuiKeymap, context: &str, action: &str) -> Result
|
||||
Ok(slot.is_some())
|
||||
}
|
||||
|
||||
/// Bottom-pane view that captures a single key event for a pending `/keymap` edit.
|
||||
///
|
||||
/// The view is deliberately transient: it renders instructions, accepts one
|
||||
/// keypress, and emits the captured key to the app layer. It does not mutate
|
||||
/// config itself, because mutation needs the latest runtime keymap to detect
|
||||
/// conflicts and stale selections.
|
||||
pub(crate) struct KeymapCaptureView {
|
||||
context: String,
|
||||
action: String,
|
||||
@@ -874,6 +926,7 @@ mod tests {
|
||||
"Composer.queue",
|
||||
"Global.open_external_editor",
|
||||
"Global.copy",
|
||||
"Global.toggle_vim_mode",
|
||||
"Editor.delete_backward_word",
|
||||
"Editor.delete_forward_word",
|
||||
"Editor.move_word_left",
|
||||
@@ -984,8 +1037,9 @@ mod tests {
|
||||
let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID);
|
||||
|
||||
assert_eq!(unbound_tab.items.len(), 1);
|
||||
assert_eq!(unbound_tab.items[0].name, "No unbound shortcuts");
|
||||
assert!(unbound_tab.items[0].is_disabled);
|
||||
assert_eq!(unbound_tab.items[0].name, "Toggle Vim Mode");
|
||||
assert_eq!(unbound_tab.items[0].description.as_deref(), Some("unbound"));
|
||||
assert!(!unbound_tab.items[0].is_disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
//! Catalog and accessors for keymap actions shown by `/keymap`.
|
||||
//!
|
||||
//! The descriptor table is the single UI-facing inventory of configurable
|
||||
//! actions. Each descriptor ties together the config path segment, user-facing
|
||||
//! context label, stable action name, and short description used by the picker
|
||||
//! and action menu.
|
||||
//!
|
||||
//! The accessors below deliberately mirror the descriptor table for both the
|
||||
//! editable root config and the resolved runtime keymap. Keeping those matches
|
||||
//! in one module makes it easier to audit a new action: if it appears in the
|
||||
//! catalog, it must also be readable from runtime state and writable in
|
||||
//! `TuiKeymap`.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
@@ -10,9 +21,13 @@ use crate::keymap::RuntimeKeymap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct KeymapActionDescriptor {
|
||||
/// Config context segment, such as `composer` in `tui.keymap.composer.submit`.
|
||||
pub(super) context: &'static str,
|
||||
/// Human-readable group label shown in the picker.
|
||||
pub(super) context_label: &'static str,
|
||||
/// Config action segment, such as `submit` in `tui.keymap.composer.submit`.
|
||||
pub(super) action: &'static str,
|
||||
/// Short user-facing explanation of what the action does.
|
||||
pub(super) description: &'static str,
|
||||
}
|
||||
|
||||
@@ -36,6 +51,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("global", "Global", "open_external_editor", "Open the current draft in an external editor."),
|
||||
action("global", "Global", "copy", "Copy the last agent response to the clipboard."),
|
||||
action("global", "Global", "clear_terminal", "Clear the terminal UI."),
|
||||
action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."),
|
||||
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
||||
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
|
||||
@@ -60,6 +76,40 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("editor", "Editor", "kill_line_start", "Delete from cursor to line start."),
|
||||
action("editor", "Editor", "kill_line_end", "Delete from cursor to line end."),
|
||||
action("editor", "Editor", "yank", "Paste the kill buffer."),
|
||||
action("vim_normal", "Vim normal", "enter_insert", "Enter insert mode at the cursor."),
|
||||
action("vim_normal", "Vim normal", "append_after_cursor", "Enter insert mode after the cursor."),
|
||||
action("vim_normal", "Vim normal", "append_line_end", "Enter insert mode at end of line."),
|
||||
action("vim_normal", "Vim normal", "insert_line_start", "Enter insert mode at the first non-blank character."),
|
||||
action("vim_normal", "Vim normal", "open_line_below", "Open a new line below and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "open_line_above", "Open a new line above and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "move_left", "Move left in Vim normal mode."),
|
||||
action("vim_normal", "Vim normal", "move_right", "Move right in Vim normal mode."),
|
||||
action("vim_normal", "Vim normal", "move_up", "Move up or recall older history in Vim normal mode."),
|
||||
action("vim_normal", "Vim normal", "move_down", "Move down or recall newer history in Vim normal mode."),
|
||||
action("vim_normal", "Vim normal", "move_word_forward", "Move to the start of the next word."),
|
||||
action("vim_normal", "Vim normal", "move_word_backward", "Move to the start of the previous word."),
|
||||
action("vim_normal", "Vim normal", "move_word_end", "Move to the end of the current or next word."),
|
||||
action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."),
|
||||
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
|
||||
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
|
||||
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
|
||||
action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."),
|
||||
action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."),
|
||||
action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."),
|
||||
action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."),
|
||||
action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."),
|
||||
action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."),
|
||||
action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."),
|
||||
action("vim_operator", "Vim operator", "motion_left", "Operator motion left."),
|
||||
action("vim_operator", "Vim operator", "motion_right", "Operator motion right."),
|
||||
action("vim_operator", "Vim operator", "motion_up", "Operator motion up."),
|
||||
action("vim_operator", "Vim operator", "motion_down", "Operator motion down."),
|
||||
action("vim_operator", "Vim operator", "motion_word_forward", "Operator motion to start of next word."),
|
||||
action("vim_operator", "Vim operator", "motion_word_backward", "Operator motion to start of previous word."),
|
||||
action("vim_operator", "Vim operator", "motion_word_end", "Operator motion to end of word."),
|
||||
action("vim_operator", "Vim operator", "motion_line_start", "Operator motion to line start."),
|
||||
action("vim_operator", "Vim operator", "motion_line_end", "Operator motion to line end."),
|
||||
action("vim_operator", "Vim operator", "cancel", "Cancel the pending operator."),
|
||||
action("pager", "Pager", "scroll_up", "Scroll up by one row."),
|
||||
action("pager", "Pager", "scroll_down", "Scroll down by one row."),
|
||||
action("pager", "Pager", "page_up", "Scroll up by one page."),
|
||||
@@ -84,6 +134,11 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("approval", "Approval", "cancel", "Cancel an elicitation request."),
|
||||
];
|
||||
|
||||
/// Convert a stable action identifier into a display label.
|
||||
///
|
||||
/// This is intentionally presentation-only: the returned string must never be
|
||||
/// parsed back into an action name, because underscores and casing are part of
|
||||
/// the stable config contract.
|
||||
pub(super) fn action_label(action: &str) -> String {
|
||||
action
|
||||
.split('_')
|
||||
@@ -99,6 +154,12 @@ pub(super) fn action_label(action: &str) -> String {
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
/// Return the mutable root-config binding slot for one catalog action.
|
||||
///
|
||||
/// The returned `Option<KeybindingsSpec>` distinguishes three states that the
|
||||
/// editor must preserve: absent means use fallback/default resolution, `Some`
|
||||
/// with one or more keys is a custom binding, and `Some(Many([]))` is an
|
||||
/// explicit unbind.
|
||||
pub(super) fn binding_slot<'a>(
|
||||
keymap: &'a mut TuiKeymap,
|
||||
context: &str,
|
||||
@@ -109,6 +170,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("global", "open_external_editor") => Some(&mut keymap.global.open_external_editor),
|
||||
("global", "copy") => Some(&mut keymap.global.copy),
|
||||
("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal),
|
||||
("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode),
|
||||
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
||||
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
|
||||
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
||||
@@ -133,6 +195,40 @@ pub(super) fn binding_slot<'a>(
|
||||
("editor", "kill_line_start") => Some(&mut keymap.editor.kill_line_start),
|
||||
("editor", "kill_line_end") => Some(&mut keymap.editor.kill_line_end),
|
||||
("editor", "yank") => Some(&mut keymap.editor.yank),
|
||||
("vim_normal", "enter_insert") => Some(&mut keymap.vim_normal.enter_insert),
|
||||
("vim_normal", "append_after_cursor") => Some(&mut keymap.vim_normal.append_after_cursor),
|
||||
("vim_normal", "append_line_end") => Some(&mut keymap.vim_normal.append_line_end),
|
||||
("vim_normal", "insert_line_start") => Some(&mut keymap.vim_normal.insert_line_start),
|
||||
("vim_normal", "open_line_below") => Some(&mut keymap.vim_normal.open_line_below),
|
||||
("vim_normal", "open_line_above") => Some(&mut keymap.vim_normal.open_line_above),
|
||||
("vim_normal", "move_left") => Some(&mut keymap.vim_normal.move_left),
|
||||
("vim_normal", "move_right") => Some(&mut keymap.vim_normal.move_right),
|
||||
("vim_normal", "move_up") => Some(&mut keymap.vim_normal.move_up),
|
||||
("vim_normal", "move_down") => Some(&mut keymap.vim_normal.move_down),
|
||||
("vim_normal", "move_word_forward") => Some(&mut keymap.vim_normal.move_word_forward),
|
||||
("vim_normal", "move_word_backward") => Some(&mut keymap.vim_normal.move_word_backward),
|
||||
("vim_normal", "move_word_end") => Some(&mut keymap.vim_normal.move_word_end),
|
||||
("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start),
|
||||
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
|
||||
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
|
||||
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
|
||||
("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line),
|
||||
("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after),
|
||||
("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator),
|
||||
("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator),
|
||||
("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator),
|
||||
("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line),
|
||||
("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line),
|
||||
("vim_operator", "motion_left") => Some(&mut keymap.vim_operator.motion_left),
|
||||
("vim_operator", "motion_right") => Some(&mut keymap.vim_operator.motion_right),
|
||||
("vim_operator", "motion_up") => Some(&mut keymap.vim_operator.motion_up),
|
||||
("vim_operator", "motion_down") => Some(&mut keymap.vim_operator.motion_down),
|
||||
("vim_operator", "motion_word_forward") => Some(&mut keymap.vim_operator.motion_word_forward),
|
||||
("vim_operator", "motion_word_backward") => Some(&mut keymap.vim_operator.motion_word_backward),
|
||||
("vim_operator", "motion_word_end") => Some(&mut keymap.vim_operator.motion_word_end),
|
||||
("vim_operator", "motion_line_start") => Some(&mut keymap.vim_operator.motion_line_start),
|
||||
("vim_operator", "motion_line_end") => Some(&mut keymap.vim_operator.motion_line_end),
|
||||
("vim_operator", "cancel") => Some(&mut keymap.vim_operator.cancel),
|
||||
("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up),
|
||||
("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down),
|
||||
("pager", "page_up") => Some(&mut keymap.pager.page_up),
|
||||
@@ -160,6 +256,11 @@ pub(super) fn binding_slot<'a>(
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
/// Return the resolved runtime bindings for one catalog action.
|
||||
///
|
||||
/// This reads from [`RuntimeKeymap`] rather than root config so UI labels show
|
||||
/// the actual active binding after defaults, global fallback, explicit
|
||||
/// unbinding, and duplicate-key validation have already been applied.
|
||||
pub(super) fn bindings_for_action<'a>(
|
||||
runtime_keymap: &'a RuntimeKeymap,
|
||||
context: &str,
|
||||
@@ -170,6 +271,7 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("global", "open_external_editor") => Some(runtime_keymap.app.open_external_editor.as_slice()),
|
||||
("global", "copy") => Some(runtime_keymap.app.copy.as_slice()),
|
||||
("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()),
|
||||
("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()),
|
||||
("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()),
|
||||
("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()),
|
||||
("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()),
|
||||
@@ -194,6 +296,40 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()),
|
||||
("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()),
|
||||
("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()),
|
||||
("vim_normal", "enter_insert") => Some(runtime_keymap.vim_normal.enter_insert.as_slice()),
|
||||
("vim_normal", "append_after_cursor") => Some(runtime_keymap.vim_normal.append_after_cursor.as_slice()),
|
||||
("vim_normal", "append_line_end") => Some(runtime_keymap.vim_normal.append_line_end.as_slice()),
|
||||
("vim_normal", "insert_line_start") => Some(runtime_keymap.vim_normal.insert_line_start.as_slice()),
|
||||
("vim_normal", "open_line_below") => Some(runtime_keymap.vim_normal.open_line_below.as_slice()),
|
||||
("vim_normal", "open_line_above") => Some(runtime_keymap.vim_normal.open_line_above.as_slice()),
|
||||
("vim_normal", "move_left") => Some(runtime_keymap.vim_normal.move_left.as_slice()),
|
||||
("vim_normal", "move_right") => Some(runtime_keymap.vim_normal.move_right.as_slice()),
|
||||
("vim_normal", "move_up") => Some(runtime_keymap.vim_normal.move_up.as_slice()),
|
||||
("vim_normal", "move_down") => Some(runtime_keymap.vim_normal.move_down.as_slice()),
|
||||
("vim_normal", "move_word_forward") => Some(runtime_keymap.vim_normal.move_word_forward.as_slice()),
|
||||
("vim_normal", "move_word_backward") => Some(runtime_keymap.vim_normal.move_word_backward.as_slice()),
|
||||
("vim_normal", "move_word_end") => Some(runtime_keymap.vim_normal.move_word_end.as_slice()),
|
||||
("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()),
|
||||
("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()),
|
||||
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
|
||||
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
|
||||
("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()),
|
||||
("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()),
|
||||
("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()),
|
||||
("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()),
|
||||
("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()),
|
||||
("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()),
|
||||
("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()),
|
||||
("vim_operator", "motion_left") => Some(runtime_keymap.vim_operator.motion_left.as_slice()),
|
||||
("vim_operator", "motion_right") => Some(runtime_keymap.vim_operator.motion_right.as_slice()),
|
||||
("vim_operator", "motion_up") => Some(runtime_keymap.vim_operator.motion_up.as_slice()),
|
||||
("vim_operator", "motion_down") => Some(runtime_keymap.vim_operator.motion_down.as_slice()),
|
||||
("vim_operator", "motion_word_forward") => Some(runtime_keymap.vim_operator.motion_word_forward.as_slice()),
|
||||
("vim_operator", "motion_word_backward") => Some(runtime_keymap.vim_operator.motion_word_backward.as_slice()),
|
||||
("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()),
|
||||
("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()),
|
||||
("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()),
|
||||
("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()),
|
||||
("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()),
|
||||
("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()),
|
||||
("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()),
|
||||
@@ -220,6 +356,11 @@ pub(super) fn bindings_for_action<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a resolved binding list for compact menu display.
|
||||
///
|
||||
/// Duplicate runtime variants that normalize to the same config spec are shown
|
||||
/// once so compatibility defaults, such as alternate SHIFT reporting forms, do
|
||||
/// not look like separate user choices.
|
||||
pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String {
|
||||
let mut seen = BTreeSet::new();
|
||||
let specs = bindings
|
||||
|
||||
@@ -60,6 +60,7 @@ const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[
|
||||
("composer", "queue"),
|
||||
("global", "open_external_editor"),
|
||||
("global", "copy"),
|
||||
("global", "toggle_vim_mode"),
|
||||
("editor", "delete_backward_word"),
|
||||
("editor", "delete_forward_word"),
|
||||
("editor", "move_word_left"),
|
||||
@@ -94,6 +95,12 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
|
||||
description: "Inline editor movement and editing shortcuts.",
|
||||
contexts: &["editor"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "vim-shortcuts",
|
||||
label: "Vim",
|
||||
description: "Vim normal-mode and operator shortcuts.",
|
||||
contexts: &["vim_normal", "vim_operator"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "navigation-shortcuts",
|
||||
label: "Navigation",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
@@ -16,6 +17,9 @@ pub trait Renderable {
|
||||
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
|
||||
None
|
||||
}
|
||||
fn cursor_style(&self, _area: Rect) -> SetCursorStyle {
|
||||
SetCursorStyle::DefaultUserShape
|
||||
}
|
||||
}
|
||||
|
||||
pub enum RenderableItem<'a> {
|
||||
@@ -44,6 +48,13 @@ impl<'a> Renderable for RenderableItem<'a> {
|
||||
RenderableItem::Borrowed(child) => child.cursor_pos(area),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
match self {
|
||||
RenderableItem::Owned(child) => child.cursor_style(area),
|
||||
RenderableItem::Borrowed(child) => child.cursor_style(area),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<Box<dyn Renderable + 'a>> for RenderableItem<'a> {
|
||||
@@ -127,6 +138,18 @@ impl<R: Renderable> Renderable for Option<R> {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.as_ref()
|
||||
.and_then(|renderable| renderable.cursor_pos(area))
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
self.as_ref()
|
||||
.map_or(SetCursorStyle::DefaultUserShape, |renderable| {
|
||||
renderable.cursor_style(area)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Renderable> Renderable for Arc<R> {
|
||||
@@ -136,6 +159,12 @@ impl<R: Renderable> Renderable for Arc<R> {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.as_ref().desired_height(width)
|
||||
}
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.as_ref().cursor_pos(area)
|
||||
}
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
self.as_ref().cursor_style(area)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ColumnRenderable<'a> {
|
||||
@@ -180,6 +209,19 @@ impl Renderable for ColumnRenderable<'_> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
let mut y = area.y;
|
||||
for child in &self.children {
|
||||
let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width))
|
||||
.intersection(area);
|
||||
if !child_area.is_empty() && child.cursor_pos(child_area).is_some() {
|
||||
return child.cursor_style(child_area);
|
||||
}
|
||||
y += child_area.height;
|
||||
}
|
||||
SetCursorStyle::DefaultUserShape
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ColumnRenderable<'a> {
|
||||
@@ -304,6 +346,19 @@ impl<'a> Renderable for FlexRenderable<'a> {
|
||||
.zip(self.children.iter())
|
||||
.find_map(|(rect, child)| child.child.cursor_pos(rect))
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
self.allocate(area)
|
||||
.into_iter()
|
||||
.zip(self.children.iter())
|
||||
.find_map(|(rect, child)| {
|
||||
child
|
||||
.child
|
||||
.cursor_pos(rect)
|
||||
.map(|_| child.child.cursor_style(rect))
|
||||
})
|
||||
.unwrap_or(SetCursorStyle::DefaultUserShape)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RowRenderable<'a> {
|
||||
@@ -354,6 +409,19 @@ impl Renderable for RowRenderable<'_> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
let mut x = area.x;
|
||||
for (width, child) in &self.children {
|
||||
let available_width = area.width.saturating_sub(x - area.x);
|
||||
let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height);
|
||||
if !child_area.is_empty() && child.cursor_pos(child_area).is_some() {
|
||||
return child.cursor_style(child_area);
|
||||
}
|
||||
x = x.saturating_add(*width);
|
||||
}
|
||||
SetCursorStyle::DefaultUserShape
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> RowRenderable<'a> {
|
||||
@@ -385,6 +453,10 @@ impl<'a> Renderable for InsetRenderable<'a> {
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.child.cursor_pos(area.inset(self.insets))
|
||||
}
|
||||
|
||||
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
|
||||
self.child.cursor_style(area.inset(self.insets))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> InsetRenderable<'a> {
|
||||
|
||||
@@ -17,6 +17,7 @@ pub enum SlashCommand {
|
||||
Approvals,
|
||||
Permissions,
|
||||
Keymap,
|
||||
Vim,
|
||||
#[strum(serialize = "setup-default-sandbox")]
|
||||
ElevateSandbox,
|
||||
#[strum(serialize = "sandbox-add-read-dir")]
|
||||
@@ -117,6 +118,7 @@ impl SlashCommand {
|
||||
SlashCommand::Approvals => "choose what Codex is allowed to do",
|
||||
SlashCommand::Permissions => "choose what Codex is allowed to do",
|
||||
SlashCommand::Keymap => "remap TUI shortcuts",
|
||||
SlashCommand::Vim => "toggle Vim mode for the composer",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::SandboxReadRoot => {
|
||||
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
|
||||
@@ -178,6 +180,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Permissions
|
||||
| SlashCommand::Keymap
|
||||
| SlashCommand::Vim
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::SandboxReadRoot
|
||||
| SlashCommand::Experimental
|
||||
|
||||
+1
-1
@@ -6,6 +6,7 @@ Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the trans
|
||||
Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default
|
||||
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default
|
||||
Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
@@ -13,4 +14,3 @@ Submit | enter | Composer submit Submit Submit the current composer draft. enter
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default
|
||||
History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default
|
||||
History Search Next | ctrl-s | Composer history_search_next History Search Next Move to the next history search match. ctrl-s Default
|
||||
|
||||
+3
-3
@@ -5,18 +5,18 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
50 actions, 1 customized, 0 unbound.
|
||||
85 actions, 1 customized, 1 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (0) App Composer Editor Navigation Approval
|
||||
[All] Common Customized (1) Unbound (1) App Composer Editor Vim Navigation Approval
|
||||
|
||||
Type to search shortcuts
|
||||
› Global Open Transcript ctrl-t
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
Composer * Submit ctrl-enter
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+6
-5
@@ -2,19 +2,21 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (50 selectable)
|
||||
tab: Common (18 selectable)
|
||||
tab: All (85 selectable)
|
||||
tab: Common (19 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (0) (0 selectable)
|
||||
tab: App (7 selectable)
|
||||
tab: Unbound (1) (1 selectable)
|
||||
tab: App (8 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (16 selectable)
|
||||
tab: Vim (34 selectable)
|
||||
tab: Navigation (14 selectable)
|
||||
tab: Approval (8 selectable)
|
||||
Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default
|
||||
Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default
|
||||
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default
|
||||
Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
@@ -22,4 +24,3 @@ Submit | enter | Composer submit Submit Submit the current composer draft. enter
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default
|
||||
History Search Previous | ctrl-r | Composer history_search_previous History Search Previous Open history search or move to the previous match. ctrl-r Default
|
||||
History Search Next | ctrl-s | Composer history_search_next History Search Next Move to the next history search match. ctrl-s Default
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
50 actions, 0 customized, 0 unbound.
|
||||
85 actions, 0 customized, 1 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (0) App Composer Editor
|
||||
[All] Common Customized (0) Unbound (1) App Composer Editor Vim
|
||||
Navigation Approval
|
||||
|
||||
Type to search shortcuts
|
||||
@@ -15,9 +15,9 @@ expression: "render_picker(params, 78)"
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
Composer Submit enter
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -5,18 +5,18 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
50 actions, 0 customized, 0 unbound.
|
||||
85 actions, 0 customized, 1 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (0) App Composer Editor Navigation Approval
|
||||
[All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval
|
||||
|
||||
Type to search shortcuts
|
||||
› Global Open Transcript ctrl-t
|
||||
Global Open External Editor ctrl-g
|
||||
Global Copy ctrl-o
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
Chat Edit Queued Message alt-up, shift-left
|
||||
Composer Submit enter
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::time::Duration;
|
||||
|
||||
use crossterm::Command;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::DisableFocusChange;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
@@ -187,7 +188,13 @@ fn restore_common(
|
||||
{
|
||||
first_error.get_or_insert(err);
|
||||
}
|
||||
let _ = execute!(stdout(), crossterm::cursor::Show);
|
||||
if let Err(err) = execute!(
|
||||
stdout(),
|
||||
SetCursorStyle::DefaultUserShape,
|
||||
crossterm::cursor::Show
|
||||
) {
|
||||
first_error.get_or_insert(err);
|
||||
}
|
||||
match first_error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(()),
|
||||
|
||||
Reference in New Issue
Block a user