Skip to content

Commit 8d398d3

Browse files
authored
feat(tui): add vim text object bindings (openai#24382)
## Why Vim mode currently supports some normal-mode operators and motions, but common text-object combinations like `ciw`, `daw`, `di(`, and quote/bracket variants are still missing. That makes the composer feel incomplete for users who expect operator + text object editing to work inside prompts. Closes openai#21383. ## What Changed - Add Vim pending-state support for operator/text-object sequences. - Add `c` as a normal-mode operator for text objects, so combinations like `ciw` delete the object and enter insert mode. - Support word, WORD, delimiter, and quote text objects: - `iw`, `aw`, `iW`, `aW` - `i(`, `a(`, `i)`, `a)`, `ib`, `ab` - `i[`, `a[`, `i]`, `a]` - `i{`, `a{`, `i}`, `a}`, `iB`, `aB` - `i"`, `a"`, `i'`, `a'`, `i\``, `a\`` - Add configurable keymap entries and keymap picker coverage for the new Vim text-object context. - Regenerate the config schema and update keymap picker snapshots. ## How to Test Manual smoke test: 1. Start Codex with Vim composer mode enabled. 2. Type a draft such as: ```text alpha beta gamma call(foo[bar], {"x": "hello world"}) say "one \"two\" three" now ``` 3. Put the cursor on `beta`, press `ciw`, and confirm `beta` is removed and the composer enters insert mode. 4. Escape back to normal mode, put the cursor on `gamma`, press `daw`, and confirm `gamma` plus surrounding whitespace is removed. 5. Put the cursor inside `foo[bar]`, press `di[`, and confirm only `bar` is removed. 6. Put the cursor inside `call(...)`, press `da(`, and confirm the whole parenthesized section is removed. 7. Put the cursor inside the quoted text, press `ci"`, and confirm the quote contents are removed and insert mode starts. 8. Verify cancellation does not edit text: press `d` then `Esc`, and press `d` then `i` then `Esc`. Targeted tests: - `cargo test -p codex-tui --lib vim_` - `cargo nextest run -p codex-tui keymap_setup::tests` Additional local checks: - `just write-config-schema` - `just fmt` - `just fix -p codex-tui` - `git diff --check` - `cargo insta pending-snapshots --manifest-path tui/Cargo.toml` Local full-suite note: `just test -p codex-tui` ran to completion. The keymap snapshot failures were expected and accepted. Two unrelated guardian feature-flag tests still fail locally: - `app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` - `app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history` `just argument-comment-lint` is currently blocked locally by Bazel analysis before the lint runs because `compiler-rt` has an empty `include/sanitizer/*.h` glob in the local Bazel cache. The touched Rust diff was manually inspected for opaque positional literals.
1 parent b1cbf62 commit 8d398d3

12 files changed

Lines changed: 1148 additions & 81 deletions

‎codex-rs/config/src/tui_keymap.rs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ pub struct TuiVimNormalKeymap {
233233
pub start_delete_operator: Option<KeybindingsSpec>,
234234
/// Begin yank operator; next key selects motion (`y`).
235235
pub start_yank_operator: Option<KeybindingsSpec>,
236+
/// Begin change operator; next keys select a text object.
237+
pub start_change_operator: Option<KeybindingsSpec>,
236238
/// Cancel a pending operator and return to normal mode.
237239
pub cancel_operator: Option<KeybindingsSpec>,
238240
}
@@ -268,10 +270,39 @@ pub struct TuiVimOperatorKeymap {
268270
pub motion_line_start: Option<KeybindingsSpec>,
269271
/// Motion: to end of line (`$`).
270272
pub motion_line_end: Option<KeybindingsSpec>,
273+
/// Select an inner text object after an operator.
274+
pub select_inner_text_object: Option<KeybindingsSpec>,
275+
/// Select an around text object after an operator.
276+
pub select_around_text_object: Option<KeybindingsSpec>,
271277
/// Cancel the pending operator and return to normal mode.
272278
pub cancel: Option<KeybindingsSpec>,
273279
}
274280

281+
/// Vim text-object keybindings for modal editing inside text areas.
282+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
283+
#[serde(deny_unknown_fields)]
284+
#[schemars(deny_unknown_fields)]
285+
pub struct TuiVimTextObjectKeymap {
286+
/// Text object: word.
287+
pub word: Option<KeybindingsSpec>,
288+
/// Text object: whitespace-delimited WORD.
289+
pub big_word: Option<KeybindingsSpec>,
290+
/// Text object: parentheses.
291+
pub parentheses: Option<KeybindingsSpec>,
292+
/// Text object: brackets.
293+
pub brackets: Option<KeybindingsSpec>,
294+
/// Text object: braces.
295+
pub braces: Option<KeybindingsSpec>,
296+
/// Text object: double quotes.
297+
pub double_quote: Option<KeybindingsSpec>,
298+
/// Text object: single quotes.
299+
pub single_quote: Option<KeybindingsSpec>,
300+
/// Text object: backticks.
301+
pub backtick: Option<KeybindingsSpec>,
302+
/// Cancel the pending text-object command.
303+
pub cancel: Option<KeybindingsSpec>,
304+
}
305+
275306
/// Pager context keybindings for transcript and static overlays.
276307
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
277308
#[serde(deny_unknown_fields)]
@@ -376,6 +407,8 @@ pub struct TuiKeymap {
376407
#[serde(default)]
377408
pub vim_operator: TuiVimOperatorKeymap,
378409
#[serde(default)]
410+
pub vim_text_object: TuiVimTextObjectKeymap,
411+
#[serde(default)]
379412
pub pager: TuiPagerKeymap,
380413
#[serde(default)]
381414
pub list: TuiListKeymap,
@@ -562,6 +595,20 @@ mod tests {
562595
);
563596
}
564597

598+
#[test]
599+
fn misspelled_vim_text_object_action_is_rejected() {
600+
let toml_input = r#"
601+
[vim_text_object]
602+
double_quotes = "shift-quote"
603+
"#;
604+
let err = toml::from_str::<TuiKeymap>(toml_input)
605+
.expect_err("expected unknown vim text object action");
606+
assert!(
607+
err.to_string().contains("double_quotes"),
608+
"expected error to mention misspelled field, got: {err}"
609+
);
610+
}
611+
565612
#[test]
566613
fn removed_backtrack_actions_are_rejected() {
567614
for (context, action) in [

‎codex-rs/core/config.schema.json‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2851,6 +2851,7 @@
28512851
"open_line_above": null,
28522852
"open_line_below": null,
28532853
"paste_after": null,
2854+
"start_change_operator": null,
28542855
"start_delete_operator": null,
28552856
"start_yank_operator": null,
28562857
"yank_line": null
@@ -2867,7 +2868,20 @@
28672868
"motion_word_backward": null,
28682869
"motion_word_end": null,
28692870
"motion_word_forward": null,
2871+
"select_around_text_object": null,
2872+
"select_inner_text_object": null,
28702873
"yank_line": null
2874+
},
2875+
"vim_text_object": {
2876+
"backtick": null,
2877+
"big_word": null,
2878+
"braces": null,
2879+
"brackets": null,
2880+
"cancel": null,
2881+
"double_quote": null,
2882+
"parentheses": null,
2883+
"single_quote": null,
2884+
"word": null
28712885
}
28722886
},
28732887
"description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings."
@@ -3518,6 +3532,7 @@
35183532
"open_line_above": null,
35193533
"open_line_below": null,
35203534
"paste_after": null,
3535+
"start_change_operator": null,
35213536
"start_delete_operator": null,
35223537
"start_yank_operator": null,
35233538
"yank_line": null
@@ -3541,8 +3556,28 @@
35413556
"motion_word_backward": null,
35423557
"motion_word_end": null,
35433558
"motion_word_forward": null,
3559+
"select_around_text_object": null,
3560+
"select_inner_text_object": null,
35443561
"yank_line": null
35453562
}
3563+
},
3564+
"vim_text_object": {
3565+
"allOf": [
3566+
{
3567+
"$ref": "#/definitions/TuiVimTextObjectKeymap"
3568+
}
3569+
],
3570+
"default": {
3571+
"backtick": null,
3572+
"big_word": null,
3573+
"braces": null,
3574+
"brackets": null,
3575+
"cancel": null,
3576+
"double_quote": null,
3577+
"parentheses": null,
3578+
"single_quote": null,
3579+
"word": null
3580+
}
35463581
}
35473582
},
35483583
"type": "object"
@@ -3903,6 +3938,14 @@
39033938
],
39043939
"description": "Paste after cursor (`p`)."
39053940
},
3941+
"start_change_operator": {
3942+
"allOf": [
3943+
{
3944+
"$ref": "#/definitions/KeybindingsSpec"
3945+
}
3946+
],
3947+
"description": "Begin change operator; next keys select a text object."
3948+
},
39063949
"start_delete_operator": {
39073950
"allOf": [
39083951
{
@@ -4022,6 +4065,22 @@
40224065
],
40234066
"description": "Motion: to start of next word (`w`)."
40244067
},
4068+
"select_around_text_object": {
4069+
"allOf": [
4070+
{
4071+
"$ref": "#/definitions/KeybindingsSpec"
4072+
}
4073+
],
4074+
"description": "Select an around text object after an operator."
4075+
},
4076+
"select_inner_text_object": {
4077+
"allOf": [
4078+
{
4079+
"$ref": "#/definitions/KeybindingsSpec"
4080+
}
4081+
],
4082+
"description": "Select an inner text object after an operator."
4083+
},
40254084
"yank_line": {
40264085
"allOf": [
40274086
{
@@ -4033,6 +4092,85 @@
40334092
},
40344093
"type": "object"
40354094
},
4095+
"TuiVimTextObjectKeymap": {
4096+
"additionalProperties": false,
4097+
"description": "Vim text-object keybindings for modal editing inside text areas.",
4098+
"properties": {
4099+
"backtick": {
4100+
"allOf": [
4101+
{
4102+
"$ref": "#/definitions/KeybindingsSpec"
4103+
}
4104+
],
4105+
"description": "Text object: backticks."
4106+
},
4107+
"big_word": {
4108+
"allOf": [
4109+
{
4110+
"$ref": "#/definitions/KeybindingsSpec"
4111+
}
4112+
],
4113+
"description": "Text object: whitespace-delimited WORD."
4114+
},
4115+
"braces": {
4116+
"allOf": [
4117+
{
4118+
"$ref": "#/definitions/KeybindingsSpec"
4119+
}
4120+
],
4121+
"description": "Text object: braces."
4122+
},
4123+
"brackets": {
4124+
"allOf": [
4125+
{
4126+
"$ref": "#/definitions/KeybindingsSpec"
4127+
}
4128+
],
4129+
"description": "Text object: brackets."
4130+
},
4131+
"cancel": {
4132+
"allOf": [
4133+
{
4134+
"$ref": "#/definitions/KeybindingsSpec"
4135+
}
4136+
],
4137+
"description": "Cancel the pending text-object command."
4138+
},
4139+
"double_quote": {
4140+
"allOf": [
4141+
{
4142+
"$ref": "#/definitions/KeybindingsSpec"
4143+
}
4144+
],
4145+
"description": "Text object: double quotes."
4146+
},
4147+
"parentheses": {
4148+
"allOf": [
4149+
{
4150+
"$ref": "#/definitions/KeybindingsSpec"
4151+
}
4152+
],
4153+
"description": "Text object: parentheses."
4154+
},
4155+
"single_quote": {
4156+
"allOf": [
4157+
{
4158+
"$ref": "#/definitions/KeybindingsSpec"
4159+
}
4160+
],
4161+
"description": "Text object: single quotes."
4162+
},
4163+
"word": {
4164+
"allOf": [
4165+
{
4166+
"$ref": "#/definitions/KeybindingsSpec"
4167+
}
4168+
],
4169+
"description": "Text object: word."
4170+
}
4171+
},
4172+
"type": "object"
4173+
},
40364174
"UriBasedFileOpener": {
40374175
"oneOf": [
40384176
{

0 commit comments

Comments
 (0)