vim mode: support for c* (change) operations

Open 💬 3 comments Opened Jul 13, 2026 by NikoPit

What variant of Codex are you using?

CLI

What feature would you like to see?

Support for c* (e.g: c$ ce cw) operations in codex cli vim mode.

Additional information

_No response_

View original on GitHub ↗

3 Comments

mrfandu1 · 1 month ago

Did some digging into why c + motion does nothing while ciw/caw work fine.

In codex-rs/tui/src/bottom_pane/textarea.rs, the change operator is already wired as an operator-pending state (VimPending::Operator(VimOperator::Change)), and text objects work because handle_vim_text_object routes through apply_vim_operator_to_range, which kills the range and enters insert mode.

Motions, however, are explicitly excluded for Change, in two places:

// handle_vim_operator
if op != VimOperator::Change
    && let Some(motion) = self.vim_motion_for_event(event)
{
    self.apply_vim_operator(op, motion);
    return true;
}
// apply_vim_operator
match op {
    VimOperator::Delete => self.kill_range(range),
    VimOperator::Yank => self.yank_range(range),
    VimOperator::Change => {}
}

So c$ / ce / cw resolve a motion, hit the guard, and are consumed as no-ops. There's a test locking this in (vim_text_object_cancellation_and_unsupported_change_motions_do_not_edit), so it looks like a deliberate punt rather than an oversight.

Outline of a fix:

  1. Drop the op != VimOperator::Change guard and have apply_vim_operator delegate to apply_vim_operator_to_range (this also dedupes the two match blocks; change = kill range + enter insert).
  2. That alone gives correct c$, c0, ce, cb, ch, cl. For example, ce inherits de's range via range_for_motion(VimMotion::WordEnd), which already matches vim's inclusive word-end semantics.
  3. cw needs vim's special case: on a non-blank character it behaves like ce, meaning it must not eat the whitespace after the word, and on the last character of a word it changes just that character (unlike ce, which would extend to the next word's end). The existing small_word_range_at_cursor() (used by ciw) already computes the right word piece; cursor..piece.end covers the cursor-inside-a-word case, with a fallback to the plain WordForward range when the cursor sits on whitespace.
  4. Linewise change (cc, cj, ck) needs different semantics (kill the lines, leave one empty line, enter insert), and cc would need a change_line entry in VimOperatorKeymap following the dd/yy pattern (config field + defaults + keymap-setup action + just write-config-schema). That could be a follow-up to keep the first change small.

The test above would flip to asserting the new behavior, plus new cases for cw mid-word, on the last char of a word, and on whitespace.

Happy to put together a PR along these lines if the team wants to invite one.

jzila · 14 days ago

I implemented the analysis above as a focused, one-commit reference patch, including the cw special case, linewise change, and counts:

Behavior covered:

  • cw uses Vim’s change-to-word-end semantics instead of consuming the following whitespace.
  • cc changes the current line while leaving an editable empty line.
  • Change works with the existing configured motions, including ch, cl, cj, ck, cw, cb, ce, c0, and c$.
  • Existing ciw and caw behavior remains intact.
  • Counts work on commands, operators, motions, and text objects: examples include 2cw, 2dd, 2cc, d2w, 2d3w, 2ciw, d2aw, and 2w2x.
  • change_line is exposed through the configurable Vim operator keymap and the generated config schema.

I intentionally split this away from the startup-mode and undo/redo work so it is independently reviewable. The patch passed all 255 codex-config tests and all 3,524 codex-tui tests, plus scoped lint and formatting.

Since unsolicited PRs are closed without review, please treat this as a tested reference implementation. Would a maintainer like to pick it up internally or explicitly invite the focused PR?

jzila · 14 days ago

@fcoury-oai — since you handled the earlier Vim composer work (#18595, #24380, #24382), I’d appreciate your take on this implementation. I split the work into three independently tested, one-commit branches because external PRs are invitation-only. If any align with the intended direction, I’m happy to open whichever PR(s) you explicitly invite.