Skip to content

feat(vim): optional vim-style modal editing in the compose entry#543

Open
sypianski wants to merge 7 commits into
d99kris:masterfrom
sypianski:vim-only
Open

feat(vim): optional vim-style modal editing in the compose entry#543
sypianski wants to merge 7 commits into
d99kris:masterfrom
sypianski:vim-only

Conversation

@sypianski

Copy link
Copy Markdown

Closes #535.

What this adds

An optional vim modal editing mode for the message compose entry, enabled by setting vim_mode=1 in ~/.config/nchat/ui.conf. Default is 0 — zero overhead, existing behaviour unchanged.

Commands (normal mode)

Group Commands
Motions h l 0 ^ $, w e b W E B, ( ) sentence, { } paragraph, j k, gg G, f F t T
Operators d c y + any motion; dd cc yy (linewise); D C Y
Text objects iw aw is as ip ap i" a" i' a' i\ a` i( a( i[ a[ i{ a{ i< a<`
Visual v → character-wise selection; d c y x; visual text objects (viw, vi", vi( …)
Edit x X s S o O p P r ~ J
Counts 3w, d3w, 2dd, …
Repeat . repeats last mutating op (including text-object changes: ciw.)
Clipboard p/P sync with system clipboard (X11/Wayland/macOS)

Mode is shown as a colored badge in the status bar and the cursor changes shape (bar in insert, block in normal). Badge colors configurable via color.conf (vim_normal_* / vim_insert_* / vim_visual_*).

Other additions in this branch

  • Send on double-Entersend_on_double_enter=1 in ui.conf (default 0): single Enter inserts newline, second consecutive Enter sends

Test plan

  • vim_mode=0 (default): no behavioral change, no overhead
  • vim_mode=1: ESC enters normal mode, i/a/A/I returns to insert
  • Motions move cursor correctly in normal mode
  • d/c/y + motion operates on correct range; register available via p
  • Text objects: ciw, di", ya( operate correctly
  • Visual mode: v starts selection, d/y/c acts on it; viw expands to word
  • . repeats last change
  • p pastes text copied from outside nchat (clipboard sync)
  • Badge color updates on mode change; correct badge in status bar
  • send_on_double_enter=1: double Enter sends, single Enter inserts newline

sypianski and others added 7 commits June 21, 2026 09:06
Add an optional vim mode to the message compose entry, toggled with
`vim_mode=1` in ui.conf. Implements a composable operator+motion engine
rather than hardcoding key combinations.

Engine (uimodel.cpp):
- Motions: h l 0 ^ $ w e b W E B, ( ) sentences, { } paragraphs,
  j k gg G, f F t T find-char. Counts (3w, d3w, 2dd).
- Operators d c y composed with any motion; dd cc yy linewise; D C.
- Edit: x X, s S substitute, o O open-line, p P paste register.
- Visual mode (v) with reverse-video selection highlight, operators
  act on [anchor, cursor].
- Insert/Normal/Visual modes; starts in insert; ESC to normal.

UI feedback:
- Status-bar badge NORMAL/INSERT/VISUAL with configurable colors
  (vim_*_color_bg/fg, vim_*_attr in color.conf).
- DECSCUSR cursor shape: bar in insert, block in normal.
- Visual selection highlighted via second WordWrap pass for anchor
  screen coords (reflow-correct).
- set_escdelay(25) to minimize bare-ESC lag on mode switch.

Config defaults added: vim_mode (ui.conf), vim_*_color/attr (color.conf).
Zero overhead when vim_mode=0 (VimKeyHandler early-returns to the
existing EntryKeyHandler).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ui.conf option send_on_double_enter (default 0): when enabled, Enter
inserts a newline and a second consecutive Enter sends the message (dropping
the just-typed trailing newline). send_msg key binding is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The status bar is now tinted by protocol (blue/green/magenta), so the vim
mode badges (previously blue/green/magenta) blended in — INSERT on a
Telegram (blue) chat was invisible. Switch badge defaults to white/yellow/red
which contrast with all protocol hues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switch vim mode badges to the standard vim/airline scheme — NORMAL blue,
INSERT green, VISUAL yellow — using bright variants so they stay distinct
over the protocol-tinted status bar. Drop the protocol-colored INSERT badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds replace (r<char>), text-objects iw/aw/iW/aW/is/as/ip/ap and
quote/bracket variants (i"/a", i'/a', i\`/a\`, i(/a(/ib, i[/a[, i{/a{/iB,
i</a<) for c/d/y operators, ;/, to repeat last f/F/t/T, Y/J/~ commands,
linewise visual V, and . to repeat the last normal-mode mutation
(excludes change-class commands, which would require insert-mode replay).
- viw/vi"/vi( etc: i/a now works in visual mode to expand selection
  to text object (previously only worked after d/c/y operator)
- ciw/ci"/ci( . repeat: c text-objects now record m_VimLast (was
  only d/y); VimRepeatLast gains case L'c' alongside d/y
- C command now repeatable (sets m_VimLast, case L'C' in repeat)
- p/P sync with system clipboard: yank writes to Clipboard::SetText,
  paste reads Clipboard::GetText() and updates m_VimRegister so text
  copied outside nchat is immediately available via p

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New `describe` Python script calls OpenAI/Gemini/Ollama vision API with
the selected message's attachment. Result is injected as an ephemeral
local message directly below the image in the conversation history.

Key binding: alt-u (describe_image). Configurable via
describe_image_command in ui.conf. Supports same services as compose:
openai (gpt-4o-mini), gemini (gemini-2.0-flash), ollama (llava), or
any OpenAI-compatible endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@desgua

desgua commented Jun 25, 2026

Copy link
Copy Markdown

It seems to be working great.
This fix the compilation warnings:

diff --git a/src/uimodel.cpp b/src/uimodel.cpp
index ceb659db..3f6d08f3 100644
--- a/src/uimodel.cpp
+++ b/src/uimodel.cpp
@@ -310,7 +310,6 @@ namespace
   // around=true includes the quote chars (and one trailing or leading space).
   bool VimQuoteObjectRange(const std::wstring& s, int p, wchar_t q, bool around, int& lo, int& hi)
   {
-    int n = (int)s.size();
     int ls = VimLineStart(s, p);
     int le = VimLineEnd(s, p);
     // Find first quote at-or-after line start, count to determine pairing.
@@ -830,7 +829,7 @@ void UiModel::Impl::EntryKeyHandler(wint_t p_Key)
   {
     static const bool sendOnDoubleEnter = (UiConfig::GetNum("send_on_double_enter") == 1);
     wint_t keyLF = 0xA;
-    if (sendOnDoubleEnter && (entryPos > 0) && (entryStr.at(entryPos - 1) == keyLF))
+    if (sendOnDoubleEnter && (entryPos > 0) && (entryStr.at(entryPos - 1) == static_cast<wchar_t>(keyLF)))
     {
       // Second consecutive Enter: drop the just-typed newline and send.
       entryStr.erase(--entryPos, 1);
@@ -1150,6 +1149,8 @@ void UiModel::Impl::VimRepeatLast(int p_Count)
             ok = VimBracketObjectRange(s, pos, L'{', L'}', around, lo, hi); break;
           case L'<': case L'>':
             ok = VimBracketObjectRange(s, pos, L'<', L'>', around, lo, hi); break;
+          default:
+            break;
         }
         if (ok) VimApplyOperator(lc.kind, lo, hi - 1, true, false);
       }
@@ -1169,6 +1170,7 @@ void UiModel::Impl::VimRepeatLast(int p_Count)
         int t = VimComputeMotion(lc.motion, lc.findChar, count, incl, lw, valid);
         if (valid) VimApplyOperator(lc.kind, pos, t, incl, lw);
       }
+    default:
       break;
   }
 }
@@ -1296,7 +1298,7 @@ void UiModel::Impl::VimNormalKey(wint_t p_Key)
         VimApplyOperator(op, lo, hi - 1, true /*inclusive*/, false);
         if ((op == L'd') || (op == L'c') || (op == L'y'))
         {
-          m_VimLast = VimLastChange{op, 1, 0, 0, 0, false, around ? L'a' : L'i', p_Key};
+          m_VimLast = VimLastChange{op, 1, 0, 0, 0, false, static_cast<wint_t>(around ? L'a' : L'i'), p_Key};
         }
       }
     }
@@ -1386,6 +1388,8 @@ void UiModel::Impl::VimNormalKey(wint_t p_Key)
         case L'F': findKey = L'f'; break;
         case L't': findKey = L'T'; break;
         case L'T': findKey = L't'; break;
+        default:
+          break;
       }
     }
     bool incl, lw, valid;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: optional vim-style modal editing in the compose entry

2 participants