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:
Felipe Coury
2026-04-30 21:20:51 -03:00
committed by GitHub
Unverified
parent a5ebedef67
commit b6f81257f8
33 changed files with 3328 additions and 214 deletions
+95
View File
@@ -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,
+7
View File
@@ -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.
+393 -2
View File
@@ -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": [
{
+39 -4
View File
@@ -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(),
};
+8
View File
@@ -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()),
+8 -4
View File
@@ -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));
}
})?;
+12 -1
View File
@@ -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();
}
+26
View File
@@ -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>();
+68 -63
View File
@@ -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;
+25
View File
@@ -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)]
+844 -12
View File
@@ -14,6 +14,8 @@ use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
use crate::keymap::EditorKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::VimOperatorKeymap;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement as UserTextElement;
use crossterm::event::KeyCode;
@@ -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
+23
View File
@@ -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;
+39 -8
View File
@@ -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]
+171 -1
View File
@@ -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:?}"
);
}
}
+35 -4
View File
@@ -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
View File
@@ -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();
+61 -7
View File
@@ -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(&params, 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]
+141
View File
@@ -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
+7
View File
@@ -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",
+72
View File
@@ -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> {
+3
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+8 -1
View File
@@ -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(()),