Skip to content

Commit d93530e

Browse files
ammarioammar-agent
andauthored
🤖 Add Vim mode MVP to chat input (#62)
## Summary - add a dedicated `VimTextArea` with insert and normal modes, core motions (h/j/k/l, 0, $, w, b) and edits (x, dd, yy, p/P, u, Ctrl-r) - replace the raw textarea in `ChatInput` with the new component while preserving existing send/newline/cancel interactions - ensure ESC still cancels edits or interrupts streams while normal-mode ESC remains available; suppress Vim keys when slash suggestions are open ## Context - Original user request: "Help me implement Vim support in the ChatInput box. Put it in its own component. Support the basic keybinds and modes -- like an MVP." - This PR fulfills that MVP by isolating Vim behavior in one reusable component and wiring it into the existing chat UI without touching backend or IPC layers. ## Testing - Not run (local lint/typecheck currently fail because the workspace is missing required dependencies) _Generated with `cmux`_ --------- Co-authored-by: Ammar <ammar+ai@ammar.io>
1 parent 44061ee commit d93530e

File tree

12 files changed

+1746
-66
lines changed

12 files changed

+1746
-66
lines changed

docs/AGENTS.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,19 @@ Verify with React DevTools Profiler - MarkdownCore should only re-render when co
9595
- `~/.cmux/src/<project_name>/<branch>` - Workspace directories for git worktrees
9696
- `~/.cmux/sessions/<workspace_id>/chat.jsonl` - Session chat histories
9797

98-
## Docs
98+
## Documentation Guidelines
99+
100+
**Free-floating markdown docs are not permitted.** Documentation must be organized:
101+
102+
- **User-facing docs**`./docs/` directory
103+
- **IMPORTANT**: Read `docs/README.md` first before writing user-facing documentation
104+
- User docs are built with mdbook and deployed to https://cmux.io
105+
- Must be added to `docs/SUMMARY.md` to appear in the docs
106+
- Use standard markdown + mermaid diagrams
107+
- **Developer docs** → inline with the code its documenting as comments. Consider them notes as notes to future Assistants to understand the logic more quickly.
108+
**DO NOT** create standalone documentation files in the project root or random locations.
109+
110+
### External API Docs
99111

100112
DO NOT visit https://sdk.vercel.ai/docs/ai-sdk-core. All of that content is already
101113
in `./ai-sdk-docs/**.mdx`.

docs/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,31 @@ docs/
3838
2. Add it to `src/SUMMARY.md` to make it appear in the sidebar
3939
3. Use standard markdown + mermaid diagrams
4040

41+
## Writing Guidelines
42+
43+
**Focus on what matters. Avoid documenting trivia.**
44+
45+
- **Don't document expected behavior** - If your target audience already expects it, don't state it
46+
- **Don't document obvious details** - Implementation details that "just work" don't need explanation
47+
- **Document what's different** - Deviations from expectations, gotchas, design decisions
48+
- **Document what's complex** - Multi-step workflows, non-obvious interactions, tradeoffs
49+
50+
### Examples of What NOT to Document
51+
52+
❌ "The cursor is always visible, even on empty text" - Expected Vim behavior, trivial detail
53+
54+
❌ "The save button is in the top right" - Obvious from UI, no cognitive value
55+
56+
❌ "Press Enter to submit" - Universal convention, doesn't need stating
57+
58+
### Examples of What TO Document
59+
60+
✅ "ESC exits normal mode instead of canceling edits (use Ctrl-Q)" - Different from expected behavior
61+
62+
✅ "Column position is preserved when moving up/down" - Non-obvious Vim feature some users don't know
63+
64+
✅ "Operators compose with motions: d + w = dw" - Core concept that unlocks understanding
65+
4166
### Example Mermaid Diagram
4267

4368
````markdown

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- [Introduction](./intro.md)
44
- [Install](./install.md)
55
- [Keyboard Shortcuts](./keybinds.md)
6+
- [Vim Mode](./vim-mode.md)
67
- [Context Management](./context-management.md)
78
- [Project Secrets](./project-secrets.md)
89
- [Agentic Git Identity](./agentic-git-identity.md)

docs/keybinds.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ When documentation shows `Ctrl`, it means:
2727
| Focus chat input | `a` or `i` |
2828
| Send message | `Enter` |
2929
| New line in message | `Shift+Enter` |
30+
| Cancel editing message | `Ctrl+Q` |
3031
| Jump to bottom of chat | `Shift+G` |
3132
| Change model | `Ctrl+/` |
3233

docs/vim-mode.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<!-- Keep this file in sync with:
2+
- src/components/VimTextArea.tsx (implementation)
3+
- src/utils/vim.ts (core logic)
4+
- src/utils/vim.test.ts (test suite)
5+
-->
6+
7+
# Vim Mode
8+
9+
cmux includes a built-in Vim mode for the chat input, providing familiar Vim-style editing for power users.
10+
11+
## Enabling Vim Mode
12+
13+
Vim mode is always enabled. Press **ESC** to enter normal mode from insert mode.
14+
15+
## Modes
16+
17+
### Insert Mode (Default)
18+
- This is the default mode when typing in the chat input
19+
- Type normally, all characters are inserted
20+
- Press **ESC** or **Ctrl-[** to enter normal mode
21+
22+
### Normal Mode
23+
- Command mode for navigation and editing
24+
- Indicated by "NORMAL" text above the input
25+
- Pending commands are shown (e.g., "NORMAL d" when delete is pending)
26+
- Press **i**, **a**, **I**, **A**, **o**, or **O** to return to insert mode
27+
28+
## Navigation
29+
30+
### Basic Movement
31+
- **h** - Move left one character
32+
- **j** - Move down one line
33+
- **k** - Move up one line
34+
- **l** - Move right one character
35+
36+
### Word Movement
37+
- **w** - Move forward to start of next word
38+
- **W** - Move forward to start of next WORD (whitespace-separated)
39+
- **b** - Move backward to start of previous word
40+
- **B** - Move backward to start of previous WORD
41+
- **e** - Move to end of current/next word
42+
- **E** - Move to end of current/next WORD
43+
44+
### Line Movement
45+
- **0** - Move to beginning of line
46+
- **$** - Move to end of line
47+
- **Home** - Same as **0**
48+
- **End** - Same as **$**
49+
50+
### Column Preservation
51+
When moving up/down with **j**/**k**, the cursor attempts to stay in the same column position. If a line is shorter, the cursor moves to the end of that line, but will return to the original column on longer lines.
52+
53+
## Entering Insert Mode
54+
55+
- **i** - Insert at cursor
56+
- **a** - Append after cursor
57+
- **I** - Insert at beginning of line
58+
- **A** - Append at end of line
59+
- **o** - Open new line below and insert
60+
- **O** - Open new line above and insert
61+
62+
## Editing Commands
63+
64+
### Simple Edits
65+
- **x** - Delete character under cursor
66+
- **p** - Paste after cursor
67+
- **P** - Paste before cursor
68+
69+
### Undo/Redo
70+
- **u** - Undo last change
71+
- **Ctrl-r** - Redo
72+
73+
### Line Operations
74+
- **dd** - Delete line (yank to clipboard)
75+
- **yy** - Yank (copy) line
76+
- **cc** - Change line (delete and enter insert mode)
77+
78+
## Operators + Motions
79+
80+
Vim's power comes from combining operators with motions. All operators work with all motions:
81+
82+
### Operators
83+
- **d** - Delete
84+
- **c** - Change (delete and enter insert mode)
85+
- **y** - Yank (copy)
86+
87+
### Motions
88+
- **w** - To next word
89+
- **b** - To previous word
90+
- **e** - To end of word
91+
- **$** - To end of line
92+
- **0** - To beginning of line
93+
94+
### Examples
95+
- **dw** - Delete to next word
96+
- **de** - Delete to end of word
97+
- **d$** - Delete to end of line
98+
- **cw** - Change to next word
99+
- **ce** - Change to end of word
100+
- **c0** - Change to beginning of line
101+
- **y$** - Yank to end of line
102+
- **ye** - Yank to end of word
103+
- **yy** - Yank line (doubled operator)
104+
105+
### Shortcuts
106+
- **D** - Same as **d$** (delete to end of line)
107+
- **C** - Same as **c$** (change to end of line)
108+
109+
## Text Objects
110+
111+
Text objects let you operate on semantic units:
112+
113+
### Inner Word (iw)
114+
- **diw** - Delete inner word (word under cursor)
115+
- **ciw** - Change inner word
116+
- **yiw** - Yank inner word
117+
118+
Text objects work from anywhere within the word - you don't need to be at the start.
119+
120+
## Visual Feedback
121+
122+
- **Cursor**: Thin blinking cursor in insert mode, solid block in normal mode
123+
- **Mode Indicator**: Shows current mode and pending commands (e.g., "NORMAL d" when waiting for motion)
124+
125+
## Keybind Conflicts
126+
127+
### ESC Key
128+
ESC is used for:
129+
1. Exiting Vim normal mode (highest priority)
130+
2. NOT used for canceling edits (use **Ctrl-Q** instead)
131+
3. NOT used for interrupting streams (use **Ctrl-C** instead)
132+
133+
134+
135+
## Tips
136+
137+
1. **Learn operators + motions**: Instead of memorizing every command, learn the operators (d, c, y) and motions (w, b, $, 0). They combine naturally.
138+
139+
2. **Use text objects**: `ciw` to change a word is more reliable than `cw` because it works from anywhere in the word.
140+
141+
3. **Column preservation**: When navigating up/down, your column position is preserved across lines of different lengths.
142+
143+
## Not Yet Implemented
144+
145+
Features that may be added in the future:
146+
- **ge** - Backward end of word motion
147+
- **f{char}**, **t{char}** - Find character motions
148+
- **i"**, **i'**, **i(**, **i[**, **i{** - More text objects
149+
- **2w**, **3dd**, **5x** - Count prefixes
150+
- **Visual mode** - Character, line, and block selection
151+
- **Macros** - Recording and replaying command sequences
152+
- **Marks** - Named cursor positions

src/components/ChatInput.tsx

Lines changed: 28 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ import { matchesKeybind, formatKeybind, KEYBINDS, isEditableElement } from "@/ut
2323
import { defaultModel } from "@/utils/ai/models";
2424
import { ModelSelector, type ModelSelectorRef } from "./ModelSelector";
2525
import { useModelLRU } from "@/hooks/useModelLRU";
26+
import { VimTextArea } from "./VimTextArea";
2627

2728
const InputSection = styled.div`
2829
position: relative;
29-
padding: 15px;
30+
padding: 5px 15px 15px 15px; /* Reduced top padding from 15px to 5px */
3031
background: #252526;
3132
border-top: 1px solid #3e3e42;
3233
display: flex;
@@ -40,39 +41,7 @@ const InputControls = styled.div`
4041
align-items: flex-end;
4142
`;
4243

43-
const InputField = styled.textarea<{
44-
isEditing?: boolean;
45-
canInterrupt?: boolean;
46-
mode: UIMode;
47-
}>`
48-
flex: 1;
49-
background: ${(props) => (props.isEditing ? "var(--color-editing-mode-alpha)" : "#1e1e1e")};
50-
border: 1px solid ${(props) => (props.isEditing ? "var(--color-editing-mode)" : "#3e3e42")};
51-
color: #d4d4d4;
52-
padding: 8px 12px;
53-
border-radius: 4px;
54-
font-family: inherit;
55-
font-size: 13px;
56-
resize: none;
57-
min-height: 36px;
58-
max-height: 200px;
59-
overflow-y: auto;
60-
max-height: 120px;
61-
62-
&:focus {
63-
outline: none;
64-
border-color: ${(props) =>
65-
props.isEditing
66-
? "var(--color-editing-mode)"
67-
: props.mode === "plan"
68-
? "var(--color-plan-mode)"
69-
: "var(--color-exec-mode)"};
70-
}
71-
72-
&::placeholder {
73-
color: #6b6b6b;
74-
}
75-
`;
44+
// Input now rendered by VimTextArea; styles moved there
7645

7746
const ModeToggles = styled.div`
7847
display: flex;
@@ -670,21 +639,24 @@ export const ChatInput: React.FC<ChatInputProps> = ({
670639
return;
671640
}
672641

673-
// Handle cancel/escape
674-
if (matchesKeybind(e, KEYBINDS.CANCEL)) {
675-
const isFocused = document.activeElement === inputRef.current;
676-
e.preventDefault();
677-
678-
// Cancel editing if in edit mode
642+
// Handle cancel edit (Ctrl+Q)
643+
if (matchesKeybind(e, KEYBINDS.CANCEL_EDIT)) {
679644
if (editingMessage && onCancelEdit) {
645+
e.preventDefault();
680646
onCancelEdit();
647+
const isFocused = document.activeElement === inputRef.current;
648+
if (isFocused) {
649+
inputRef.current?.blur();
650+
}
651+
return;
681652
}
653+
}
682654

683-
if (isFocused) {
684-
inputRef.current?.blur();
685-
}
686-
687-
return;
655+
// Handle escape - let VimTextArea handle it (for Vim mode transitions)
656+
// Edit canceling is handled by Ctrl+Q above
657+
// Stream interruption is handled by Ctrl+C (INTERRUPT_STREAM keybind)
658+
if (matchesKeybind(e, KEYBINDS.CANCEL)) {
659+
// Do not preventDefault here: allow VimTextArea or other handlers (like suggestions) to process ESC
688660
}
689661

690662
// Don't handle keys if command suggestions are visible
@@ -730,37 +702,33 @@ export const ChatInput: React.FC<ChatInputProps> = ({
730702
})();
731703

732704
return (
733-
<InputSection>
705+
<InputSection data-component="ChatInputSection">
734706
<ChatInputToast toast={toast} onDismiss={handleToastDismiss} />
735707
<CommandSuggestions
736708
suggestions={commandSuggestions}
737709
onSelectSuggestion={handleCommandSelect}
738710
onDismiss={() => setShowCommandSuggestions(false)}
739711
isVisible={showCommandSuggestions}
740712
/>
741-
<InputControls>
742-
<InputField
713+
<InputControls data-component="ChatInputControls">
714+
<VimTextArea
743715
ref={inputRef}
744716
value={input}
745717
isEditing={!!editingMessage}
746718
mode={mode}
747-
onChange={(e) => {
748-
const newValue = e.target.value;
749-
setInput(newValue);
750-
// Auto-resize textarea
751-
e.target.style.height = "auto";
752-
e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px";
753-
754-
// Don't clear toast when typing - let user dismiss it manually or it auto-dismisses
755-
}}
719+
onChange={setInput}
756720
onKeyDown={handleKeyDown}
721+
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
757722
placeholder={placeholder}
758723
disabled={disabled || isSending || isCompacting}
759-
canInterrupt={canInterrupt}
760724
/>
761725
</InputControls>
762-
<ModeToggles>
763-
{editingMessage && <EditingIndicator>Editing message (ESC to cancel)</EditingIndicator>}
726+
<ModeToggles data-component="ChatModeToggles">
727+
{editingMessage && (
728+
<EditingIndicator>
729+
Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel)
730+
</EditingIndicator>
731+
)}
764732
<ModeTogglesRow>
765733
<ChatToggles workspaceId={workspaceId} modelString={preferredModel}>
766734
<ModelDisplayWrapper>

src/components/Tooltip.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,15 @@ const Arrow = styled.div`
186186

187187
export const HelpIndicator = styled.span`
188188
color: #666666;
189-
font-size: 8px;
189+
font-size: 7px;
190190
cursor: help;
191191
display: inline-block;
192192
vertical-align: baseline;
193193
border: 1px solid #666666;
194194
border-radius: 50%;
195-
width: 11px;
196-
height: 11px;
197-
line-height: 9px;
195+
width: 10px;
196+
height: 10px;
197+
line-height: 8px;
198198
text-align: center;
199199
font-weight: bold;
200200
margin-bottom: 2px;

0 commit comments

Comments
 (0)