From 1628cac703eb7fad5250738ed26ec23a30c4f44d Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 08:23:45 -0800
Subject: [PATCH 01/18] feat: Add vi mode infrastructure for Pair Writing
(chunks 1-5)
Implements foundation for vi-style modal editing in Pair Writing mode:
- Configuration: Add viMode to VaultConfig, flow through to VaultInfo
- Keyboard detection: useHasKeyboard hook to gate vi mode on physical keyboard
- State machine: useViMode hook with normal/insert/command mode transitions
- Cursor overlay: useViCursor hook + ViCursor component with mirror element technique
- Mode indicator: ViModeIndicator component showing current mode
This is the infrastructure layer. Movement commands, editing, and ex commands
come in subsequent chunks.
Refs #394
Co-Authored-By: Claude Opus 4.5
---
.lore/plans/vi-mode-pair-writing.md | 233 +++++++++
.lore/research/vi-mode-implementation.md | 308 +++++++++++
.lore/specs/vi-mode-pair-writing.md | 65 +++
.lore/work/vi-mode-pair-writing.md | 222 ++++++++
backend/src/__tests__/meeting-capture.test.ts | 6 +
backend/src/__tests__/note-capture.test.ts | 2 +
backend/src/__tests__/session-manager.test.ts | 1 +
backend/src/__tests__/test-helpers.ts | 2 +
.../src/__tests__/transcript-manager.test.ts | 4 +
backend/src/__tests__/vault-config.test.ts | 141 +++++
backend/src/__tests__/vault-manager.test.ts | 6 +
.../src/__tests__/vault-resolution.test.ts | 1 +
.../src/__tests__/websocket-handler.test.ts | 1 +
.../__tests__/pair-writing-handlers.test.ts | 1 +
backend/src/vault-config.ts | 34 +-
backend/src/vault-manager.ts | 2 +
.../browse/__tests__/BrowseMode.test.tsx | 1 +
.../capture/__tests__/NoteCapture.test.tsx | 1 +
.../discussion/__tests__/Discussion.test.tsx | 1 +
.../home/__tests__/HealthPanel.test.tsx | 1 +
.../home/__tests__/HomeView.test.tsx | 1 +
.../home/__tests__/InspirationCard.test.tsx | 1 +
.../home/__tests__/RecentActivity.test.tsx | 1 +
.../src/components/pair-writing/ViCursor.tsx | 64 +++
.../pair-writing/ViModeIndicator.tsx | 59 +++
.../__tests__/ViModeIndicator.test.tsx | 188 +++++++
frontend/src/components/pair-writing/index.ts | 1 +
.../src/components/pair-writing/vi-mode.css | 75 +++
.../vault/__tests__/VaultSelect.test.tsx | 3 +
.../__tests__/SessionContext.test.tsx | 2 +
.../__tests__/reducer-update-config.test.ts | 1 +
.../src/hooks/__tests__/useViCursor.test.ts | 346 +++++++++++++
.../src/hooks/__tests__/useViMode.test.ts | 483 ++++++++++++++++++
.../src/hooks/__tests__/useWebSocket.test.ts | 1 +
frontend/src/hooks/useHasKeyboard.test.ts | 97 ++++
frontend/src/hooks/useHasKeyboard.ts | 53 ++
frontend/src/hooks/useViCursor.ts | 269 ++++++++++
frontend/src/hooks/useViMode.ts | 177 +++++++
frontend/src/test-helpers.ts | 2 +
shared/src/__tests__/protocol.test.ts | 2 +
shared/src/protocol.ts | 2 +
shared/src/types.ts | 2 +
42 files changed, 2862 insertions(+), 1 deletion(-)
create mode 100644 .lore/plans/vi-mode-pair-writing.md
create mode 100644 .lore/research/vi-mode-implementation.md
create mode 100644 .lore/specs/vi-mode-pair-writing.md
create mode 100644 .lore/work/vi-mode-pair-writing.md
create mode 100644 frontend/src/components/pair-writing/ViCursor.tsx
create mode 100644 frontend/src/components/pair-writing/ViModeIndicator.tsx
create mode 100644 frontend/src/components/pair-writing/__tests__/ViModeIndicator.test.tsx
create mode 100644 frontend/src/components/pair-writing/vi-mode.css
create mode 100644 frontend/src/hooks/__tests__/useViCursor.test.ts
create mode 100644 frontend/src/hooks/__tests__/useViMode.test.ts
create mode 100644 frontend/src/hooks/useHasKeyboard.test.ts
create mode 100644 frontend/src/hooks/useHasKeyboard.ts
create mode 100644 frontend/src/hooks/useViCursor.ts
create mode 100644 frontend/src/hooks/useViMode.ts
diff --git a/.lore/plans/vi-mode-pair-writing.md b/.lore/plans/vi-mode-pair-writing.md
new file mode 100644
index 00000000..cb826ea4
--- /dev/null
+++ b/.lore/plans/vi-mode-pair-writing.md
@@ -0,0 +1,233 @@
+# Plan: Vi Mode for Pair Writing
+
+## Context
+
+- **Spec**: `.lore/specs/vi-mode-pair-writing.md`
+- **Research**: `.lore/research/vi-mode-implementation.md`
+- **Integration target**: `PairWritingMode.tsx` and `PairWritingEditor.tsx`
+
+## Approach
+
+The vi mode implementation follows a **layered architecture** with clear separation:
+
+1. **Configuration layer** - Vault config addition, keyboard detection
+2. **State machine layer** - Mode management, key sequence buffering
+3. **Command execution layer** - Cursor manipulation, text operations
+4. **UI layer** - Mode indicator, command input
+
+The core abstraction is a `useViMode` hook that encapsulates all vi behavior and integrates with the existing `PairWritingEditor` via props.
+
+## Technical Decisions
+
+**TD-1: Hook-based architecture**
+
+Create `useViMode(textareaRef, options)` hook that returns:
+- `mode`: current mode (normal/insert/command)
+- `handleKeyDown`: event handler to attach to textarea
+- `commandBuffer`: current ex command being typed
+- `pendingCount`: numeric prefix being accumulated
+- `pendingOperator`: buffered operator awaiting motion (`d`, `y`, or null)
+- `clipboard`: internal yank buffer
+- `undoStack`: array of content snapshots for `u` command
+
+This keeps vi logic isolated and testable independent of React rendering.
+
+**TD-2: Keyboard detection via matchMedia**
+
+Use `window.matchMedia('(pointer: fine)')` combined with `navigator.maxTouchPoints` to detect keyboard availability:
+```typescript
+const hasKeyboard =
+ window.matchMedia('(pointer: fine)').matches ||
+ navigator.maxTouchPoints === 0;
+```
+
+This isn't perfect but catches most cases. If detection fails, user gets standard editing (safe fallback).
+
+**TD-3: Textarea cursor manipulation**
+
+Use `selectionStart`/`selectionEnd` properties to track and move cursor:
+- Normal mode: collapse selection to single point (cursor)
+- Movement commands: update `selectionStart` and `selectionEnd`
+- Insert mode: let browser handle naturally
+
+Line-based operations parse content by newlines to find line boundaries.
+
+**TD-4: Key event handling strategy**
+
+In Normal mode, `onKeyDown` handler:
+1. Check for numeric digit → accumulate in `pendingCount`
+2. Check for operator key (`d`, `y`) → set pending operator
+3. Check for motion/action → execute with count
+4. `preventDefault()` to stop character insertion
+
+In Insert mode:
+- Only intercept `Escape` → return to Normal
+- All other keys pass through naturally
+
+**TD-5: Command mode UI**
+
+When `:` pressed, render a small input field at bottom of editor (vim-style command line). This is a controlled input that:
+- Captures text until Enter or Escape
+- On Enter, parses and executes command
+- On Escape, dismisses without action
+
+Rendered conditionally within `PairWritingEditor` when mode is 'command'.
+
+**TD-6: Integration with existing save/exit**
+
+The hook accepts callbacks for save and exit:
+```typescript
+useViMode(textareaRef, {
+ enabled: viModeEnabled && hasKeyboard,
+ onSave: () => handleSave(),
+ onExit: () => handleExitClick(),
+ onQuitWithUnsaved: () => setShowExitConfirm(true),
+});
+```
+
+`:w` calls `onSave`, `:wq` calls both, `:q` checks `hasUnsavedChanges` and either exits or triggers confirmation.
+
+**TD-7: Line operations implementation**
+
+For `dd`, `yy`, `p`, `P`:
+- Parse content into lines array
+- Find current line by counting newlines before cursor
+- Splice/insert lines as needed
+- Reconstruct content string
+- Update cursor position appropriately
+
+**TD-8: Numeric prefix handling**
+
+Buffer digits in state until a command key arrives.
+
+Note: `0` is special - it's "start of line" when no count is pending, but a digit when accumulating (e.g., `10j`).
+
+**TD-9: Undo stack**
+
+Maintain internal undo stack since programmatic `textarea.value` changes don't create browser undo history:
+- Push content snapshot before each edit operation (`dd`, `x`, `p`, Insert mode exit)
+- `u` command pops and restores previous state
+- Stack has reasonable depth limit (e.g., 100 entries)
+- Insert mode batches changes into single undo entry (snapshot on mode enter, not per keystroke)
+
+**TD-10: Cursor rendering with overlay**
+
+Use hybrid approach: textarea for content, overlay div for block cursor in Normal mode.
+
+Architecture:
+```
+┌─────────────────────────────────────┐
+│ PairWritingEditor │
+│ ┌───────────────────────────────┐ │
+│ │ textarea (content) │ │
+│ │ caret-color: transparent │ │
+│ │ when Normal mode │ │
+│ └───────────────────────────────┘ │
+│ ┌───────────────────────────────┐ │
+│ │ .vi-cursor (overlay) │ │
+│ │ position: absolute │ │
+│ │ pointer-events: none │ │
+│ └───────────────────────────────┘ │
+│ ┌───────────────────────────────┐ │
+│ │ .vi-cursor-mirror (off-screen)│ │
+│ │ visibility: hidden │ │
+│ │ copies textarea styles │ │
+│ └───────────────────────────────┘ │
+└─────────────────────────────────────┘
+```
+
+Mirror element technique for cursor position:
+1. Create off-screen div with identical styling (font, padding, line-height, word-wrap)
+2. Split content at cursor position into before/after text nodes
+3. Insert span marker between them
+4. Measure span's `getBoundingClientRect()` for pixel coordinates
+5. Position overlay at those coordinates, adjusted for textarea scroll
+
+Mode-specific behavior:
+- **Normal mode**: Hide native caret (`caret-color: transparent`), show block cursor overlay
+- **Insert mode**: Hide overlay, show native caret (standard textarea behavior)
+- **Command mode**: Same as Normal (cursor stays visible while typing ex command)
+
+Scroll synchronization:
+```typescript
+// On textarea scroll, offset overlay position
+const updateCursorPosition = () => {
+ const pos = calculateCursorPixelPosition(textarea, cursorIndex);
+ overlay.style.left = `${pos.left - textarea.scrollLeft}px`;
+ overlay.style.top = `${pos.top - textarea.scrollTop}px`;
+};
+
+textarea.addEventListener('scroll', updateCursorPosition);
+```
+
+Cursor appearance by mode:
+- Normal: Block cursor (width ~0.6em, height ~1.2em, semi-transparent background)
+- Insert: Native line cursor (overlay hidden)
+- Command: Block cursor (same as Normal)
+
+Dependencies:
+- Can use `textarea-caret-position` npm package or implement core algorithm (~50 lines)
+- Prefer implementing core algorithm to avoid dependency for small feature
+
+**TD-8 implementation:**
+```typescript
+// State
+pendingCount: number | null
+
+// On digit
+if (/[0-9]/.test(key) && mode === 'normal') {
+ pendingCount = (pendingCount ?? 0) * 10 + parseInt(key);
+ return;
+}
+
+// On command
+const count = pendingCount ?? 1;
+pendingCount = null;
+executeCommand(key, count);
+```
+
+## Component Structure
+
+```
+frontend/src/
+├── hooks/
+│ ├── useViMode.ts # Core vi mode hook (state machine, commands)
+│ ├── useViMode.test.ts # Unit tests for vi logic
+│ ├── useViCursor.ts # Cursor position calculation and overlay management
+│ └── useViCursor.test.ts # Unit tests for cursor positioning
+├── components/
+│ └── pair-writing/
+│ ├── PairWritingEditor.tsx # Modified to integrate useViMode + cursor overlay
+│ ├── ViModeIndicator.tsx # Mode display component ("-- NORMAL --", etc.)
+│ ├── ViCommandLine.tsx # Ex command input component
+│ ├── ViCursor.tsx # Block cursor overlay component
+│ └── vi-mode.css # Styles for vi UI elements (cursor, indicator, command line)
+```
+
+## Data Flow
+
+```
+VaultConfig.viMode (backend)
+ ↓
+VaultInfo.viMode (shared type, API response)
+ ↓
+PairWritingMode reads from vault context
+ ↓
+useViMode(enabled: viMode && hasKeyboard)
+ ↓
+handleKeyDown attached to textarea
+ ↓
+State changes → re-render with mode indicator
+```
+
+## Considerations
+
+**Cursor rendering**: Uses overlay approach (TD-10) with mirror element technique. This provides vim-authentic block cursor in Normal mode while preserving native textarea behavior in Insert mode. The `useViCursor` hook encapsulates position calculation and can be tested independently.
+
+**Testing strategy**: The `useViMode` hook should be tested in isolation using a mock textarea ref. Test each command produces expected cursor position and content changes. `useViCursor` tests verify pixel position calculations. Integration tests verify the full flow in `PairWritingEditor`.
+
+**Undo behavior**: Internal undo stack (TD-9) handles `u` command. Browser `Ctrl+Z` may not work reliably for programmatic changes, so the internal stack is the primary undo mechanism. Insert mode batches all keystrokes into one undo entry.
+
+**Performance**: Line operations on large files could be slow. For v1, accept this limitation. If needed later, consider rope data structure or virtual DOM for content.
+
+**Future extensibility**: The command dispatch pattern makes adding new commands straightforward. Visual mode would add another mode state and selection tracking.
diff --git a/.lore/research/vi-mode-implementation.md b/.lore/research/vi-mode-implementation.md
new file mode 100644
index 00000000..b4ce3798
--- /dev/null
+++ b/.lore/research/vi-mode-implementation.md
@@ -0,0 +1,308 @@
+# Research: Vi Mode Implementation for Pair Writing
+
+## Summary
+
+Vi/Vim is a modal text editor where keystrokes have different meanings depending on the current mode. Implementing vi mode for Pair Writing requires a state machine managing mode transitions and a key handler that interprets input based on current mode. Several JavaScript libraries exist that implement vi for textareas, providing proven patterns.
+
+## Key Findings
+
+### Modal Architecture
+
+Vi operates in distinct modes:
+
+| Mode | Purpose | Entry | Exit |
+|------|---------|-------|------|
+| **Normal** | Navigation and commands | Default, `Esc` from other modes | `i`, `a`, `o`, `v`, `:` |
+| **Insert** | Text entry | `i`, `I`, `a`, `A`, `o`, `O` | `Esc` |
+| **Visual** | Selection | `v` (char), `V` (line) | `Esc`, action completion |
+| **Command** | Ex commands (`:w`, `:q`) | `:` from Normal | `Enter`, `Esc` |
+
+### Essential Commands (Minimum Viable)
+
+**Movement (Normal mode):**
+- `h`, `j`, `k`, `l` - Character/line movement (left, down, up, right)
+- `w`, `b` - Word forward/backward
+- `0`, `$` - Line start/end
+- `gg`, `G` - Document start/end
+
+**Mode switching:**
+- `i` - Insert before cursor
+- `I` - Insert at line start
+- `a` - Append after cursor
+- `A` - Append at line end
+- `o` - Open line below
+- `O` - Open line above
+- `Esc` - Return to Normal mode
+
+**Editing (Normal mode):**
+- `x` - Delete character
+- `dd` - Delete line
+- `yy` - Yank (copy) line
+- `p` - Put (paste) after cursor
+- `P` - Put before cursor
+- `u` - Undo
+
+**Ex commands:**
+- `:w` - Write (save)
+- `:q` - Quit
+- `:wq` or `:x` - Write and quit
+
+### Implementation Patterns from Existing Libraries
+
+**Vim.js** (~19KB, no dependencies):
+- Mode-based state machine
+- Event-driven keyboard handling
+- Numeric prefix support (`5j` = move 5 lines)
+- Dual-key sequences (`dd`, `yy`)
+- Separate clipboard/register for yank/put
+
+**VimMotions browser extension:**
+- Uses backtick (`) instead of Esc for mode exit (mobile-friendly)
+- Visual feedback via highlighting system for mode indication
+- Supports textarea, input, and contenteditable
+
+**Key architectural decisions:**
+1. State machine with explicit mode transitions
+2. Key handler dispatches to mode-specific handlers
+3. Clipboard is internal (separate from system clipboard)
+4. Visual feedback for current mode is essential
+5. Numeric prefixes require buffering keystrokes
+
+### Integration Points for Memory Loop
+
+**Current Pair Writing architecture:**
+- `PairWritingEditor.tsx` - Textarea-based editor component
+- `usePairWritingState.ts` - State hook (content, snapshot, unsaved changes)
+- `useTextSelection.ts` - Selection tracking for context menu
+- Vault config in `vault-config.ts` - No vi mode setting yet
+
+**Required additions:**
+1. `viMode?: boolean` in `VaultConfig` interface
+2. Vi mode state hook or integration into existing state
+3. Key event handler for Normal mode
+4. Mode indicator UI component
+5. Internal clipboard for yank/put operations
+
+**Configuration flow:**
+- User enables vi mode in vault config (`.memory-loop.json`)
+- Config change triggers editor behavior change
+- Editor shows mode indicator when vi mode enabled
+
+### Escape Key Alternatives for Mobile
+
+Traditional Esc is awkward on mobile keyboards. Options:
+- Backtick (`) - Used by VimMotions extension
+- `jj` or `jk` sequence - Common vim mapping
+- Swipe gesture - Native mobile feel
+- On-screen mode button - Most accessible
+
+### Scope Considerations
+
+**Minimal viable implementation:**
+- Normal and Insert modes only (no Visual initially)
+- Basic movement: `h`, `j`, `k`, `l`, `0`, `$`
+- Insert commands: `i`, `a`, `o`
+- Exit insert: `Esc` (or alternative)
+- Basic editing: `x`, `dd`
+- Yank/put: `yy`, `p`
+- Ex commands: `:w`, `:q`, `:wq`
+
+**Deferred for later:**
+- Visual mode selection
+- Word motions (`w`, `b`, `e`)
+- Change commands (`c`, `cw`, `cc`)
+- Search (`/`, `?`, `n`, `N`)
+- Marks and jumps
+- Macros and registers
+
+## Sources
+
+- [Vim Cheat Sheet](https://vim.rtorr.com/) - Comprehensive command reference
+- [Vim.js](https://github.com/toplan/Vim.js) - Lightweight textarea vim implementation
+- [VimMotions](https://github.com/RonelXavier/VimMotions) - Browser extension with mobile considerations
+- [Onivim Modal Editing 101](https://onivim.github.io/docs/getting-started/modal-editing-101) - Modal editing concepts
+- [VS Code ModalEdit Tutorial](https://johtela.github.io/vscode-modaledit/docs/tutorial.html) - Implementation patterns
+- [nixCraft Vim Save/Quit Guide](https://www.cyberciti.biz/faq/linux-unix-vim-save-and-quit-command/) - Ex command reference
+
+## Notes
+
+### For Issue #394 Specifically
+
+The issue requests:
+1. Vi mode based on vault configuration - needs config addition
+2. Mode change when config changes - reactive update
+3. Basic movement keys - `hjkl`
+4. Insert/append mode - `i`, `a`
+5. Yank, copy, put - `yy`, `p`
+6. `:` for write and quit - exits pair writing
+
+This aligns well with a minimal implementation. The `:wq` behavior mapping to "exit pair writing" is a nice fit since Pair Writing is effectively a modal editing session within the app.
+
+### Architecture Recommendation
+
+Create a `useViMode` hook that:
+1. Reads vi mode setting from vault config
+2. Manages mode state (normal/insert/command)
+3. Provides key event handler to attach to textarea
+4. Exposes mode for UI indicator
+5. Handles clipboard internally
+
+The hook integrates with `PairWritingEditor` via:
+- `onKeyDown` handler override when vi mode enabled
+- Mode indicator rendered conditionally
+- Modified cursor behavior in Normal mode
+
+## Cursor Rendering Research (Added 2026-01-29)
+
+### The Problem
+
+In Normal mode, we need to:
+1. Prevent keystrokes from inserting text
+2. Show the cursor position (preferably as a block cursor, vim-style)
+3. Handle the cursor visibility on empty lines
+
+Native textarea cursors only appear when focused and accepting input, creating a conflict with Normal mode behavior.
+
+### Approaches Found in Existing Implementations
+
+**Approach A: Hidden Textarea + Custom Rendering (Modern Editors)**
+
+Used by newer editors (not contenteditable-based). The pattern:
+1. Hidden/transparent textarea captures keyboard input
+2. Visible content rendered separately (div or canvas)
+3. Custom cursor element positioned absolutely over content
+4. Cursor position calculated via mirror element technique
+
+Pros:
+- Full control over cursor appearance (block, line, underline)
+- Predictable cross-browser behavior
+- Clean separation of input capture vs. display
+
+Cons:
+- Significant implementation complexity
+- Must sync scroll position, selection, and content between hidden input and visible display
+- More code to maintain
+
+**Approach B: Textarea + Overlay Cursor (Hybrid)**
+
+Keep textarea visible for content, add overlay for cursor:
+1. Textarea remains the source of truth for content
+2. Mirror element technique calculates cursor pixel position
+3. Absolutely positioned div renders block cursor over textarea
+4. In Normal mode: hide native caret via `caret-color: transparent`, show overlay
+5. In Insert mode: hide overlay, show native caret
+
+Mirror element technique (from [textarea-caret-position](https://github.com/component/textarea-caret-position)):
+```javascript
+// Create off-screen div with identical styling
+const mirror = document.createElement('div');
+// Copy all relevant CSS properties (font, padding, line-height, etc.)
+
+// Split text at cursor position
+const textBefore = textarea.value.substring(0, cursorPos);
+const textAfter = textarea.value.substring(cursorPos);
+
+// Build mirror content using safe DOM methods
+const cursorSpan = document.createElement('span');
+cursorSpan.textContent = '\u00A0'; // non-breaking space
+
+mirror.textContent = ''; // clear
+mirror.appendChild(document.createTextNode(textBefore));
+mirror.appendChild(cursorSpan);
+mirror.appendChild(document.createTextNode(textAfter));
+
+// Get position from span
+const rect = cursorSpan.getBoundingClientRect();
+// Returns {top, left, height} relative to viewport
+```
+
+Pros:
+- Leverages native textarea for text handling
+- Less complexity than full custom rendering
+- Can still use native selection/copy/paste
+
+Cons:
+- Must keep overlay in sync with scroll
+- Edge cases with wrapped lines
+- Two sources of cursor truth (native + overlay)
+
+**Approach C: CodeMirror's Method (for reference)**
+
+CodeMirror uses `doc.markText` to highlight the character under the cursor in Normal mode, creating a visual block effect. However:
+- Doesn't work on empty lines (no character to mark)
+- Requires CodeMirror's infrastructure
+- Not applicable to plain textarea
+
+**Approach D: ContentEditable (Not Recommended)**
+
+Some editors use contenteditable div instead of textarea:
+- Known issues with vim mode cursor on empty content
+- Inconsistent cross-browser behavior
+- DOM mutation complexities
+- "DOM is not the perfect tool for this job" - CKEditor team
+
+### CSS Properties for Cursor Control
+
+```css
+/* Hide native caret */
+textarea {
+ caret-color: transparent;
+}
+
+/* Block cursor overlay */
+.vi-cursor {
+ position: absolute;
+ background-color: var(--cursor-color);
+ opacity: 0.7;
+ pointer-events: none;
+ animation: blink 1s step-end infinite;
+}
+
+@keyframes blink {
+ 50% { opacity: 0; }
+}
+
+/* Different cursor styles by mode */
+.vi-cursor--normal {
+ width: 0.6em; /* block width */
+ height: 1.2em;
+}
+
+.vi-cursor--insert {
+ width: 2px; /* thin line */
+ height: 1.2em;
+}
+```
+
+### Scroll Synchronization
+
+When textarea scrolls, overlay must follow:
+```javascript
+textarea.addEventListener('scroll', () => {
+ cursorOverlay.style.transform =
+ `translate(-${textarea.scrollLeft}px, -${textarea.scrollTop}px)`;
+});
+```
+
+### Recommendation for Memory Loop
+
+**Approach B (Textarea + Overlay)** is the right balance:
+- Keeps existing textarea architecture
+- Adds overlay only when vi mode enabled
+- Mirror element library is ~2KB, well-tested
+- Can use `textarea-caret-position` npm package or implement core algorithm
+
+Implementation steps:
+1. Add cursor overlay div inside PairWritingEditor
+2. Calculate position on cursor move using mirror technique
+3. Toggle `caret-color: transparent` in Normal mode
+4. Sync overlay position on scroll
+5. Style overlay differently per mode
+
+### Sources
+
+- [textarea-caret-position](https://github.com/component/textarea-caret-position) - Mirror element library
+- [DEV.to: Calculate cursor coordinates](https://dev.to/phuocng/calculate-the-coordinates-of-the-current-cursor-in-a-text-area-cle) - Implementation walkthrough
+- [CodeMirror vim cursor issues](https://github.com/codemirror/codemirror5/issues/6312) - Empty line problem
+- [ContentEditable: The Good, Bad, Ugly](https://medium.com/content-uneditable/contenteditable-the-good-the-bad-and-the-ugly-261a38555e9c) - Why not contenteditable
+- [monaco-vim](https://github.com/brijeshb42/monaco-vim) - Monaco editor vim implementation
diff --git a/.lore/specs/vi-mode-pair-writing.md b/.lore/specs/vi-mode-pair-writing.md
new file mode 100644
index 00000000..b6174a9d
--- /dev/null
+++ b/.lore/specs/vi-mode-pair-writing.md
@@ -0,0 +1,65 @@
+# Spec: Vi Mode for Pair Writing
+
+## Overview
+
+Add vi-style modal editing to Pair Writing mode. When enabled via vault configuration, the editor operates in Normal mode by default where keystrokes execute navigation and editing commands rather than inserting text. Users enter Insert mode to type, and use ex commands (`:w`, `:wq`) to save and exit.
+
+## Requirements
+
+### Configuration
+- REQ-1: Add `viMode?: boolean` to VaultConfig interface
+- REQ-2: Vi mode setting read when entering Pair Writing (not live-monitored)
+- REQ-3: Vi mode only available when physical keyboard detected; disabled on touch-only devices
+
+### Modes
+- REQ-4: Support three modes: Normal (default), Insert, and Command
+- REQ-5: Mode indicator visible when vi mode enabled (e.g., "-- NORMAL --", "-- INSERT --")
+- REQ-6: Esc returns to Normal mode from Insert or Command mode
+
+### Normal Mode Commands
+- REQ-7: Movement: `h` (left), `j` (down), `k` (up), `l` (right)
+- REQ-8: Line movement: `0` (start of line), `$` (end of line)
+- REQ-9: Insert mode entry: `i` (before cursor), `a` (after cursor), `A` (end of line), `o` (new line below), `O` (new line above)
+- REQ-10: Delete: `x` (character), `dd` (line)
+- REQ-11: Yank/put: `yy` (copy line), `p` (paste after), `P` (paste before)
+- REQ-12: Undo: `u` undoes last edit operation (maintains internal undo stack)
+- REQ-13: Numeric prefixes: `[count]` before commands (e.g., `5j` moves 5 lines)
+
+### Command Mode
+- REQ-14: `:` in Normal mode opens command input
+- REQ-15: `:w` saves file, remains in Pair Writing
+- REQ-16: `:wq` saves file and exits Pair Writing
+- REQ-17: `:q` exits if no unsaved changes; shows confirmation dialog if unsaved
+- REQ-18: `:q!` exits without saving (discards changes)
+- REQ-19: `Esc` or empty input cancels command mode
+
+### Internal Clipboard
+- REQ-20: Yank/put operations use internal clipboard (separate from system)
+- REQ-21: Clipboard persists within Pair Writing session
+
+## Success Criteria
+
+- [ ] Vi mode toggle in `.memory-loop.json` enables modal editing
+- [ ] Mode indicator displays current mode
+- [ ] `hjkl` navigation works in Normal mode
+- [ ] `i`, `a`, `A`, `o`, `O` enter Insert mode at correct positions
+- [ ] Typing in Insert mode inserts text normally
+- [ ] Esc returns to Normal mode
+- [ ] `dd` deletes current line, `yy` copies it, `p` pastes
+- [ ] `u` undoes last edit operation
+- [ ] `5j` moves cursor down 5 lines
+- [ ] `:w` saves, `:wq` saves and exits, `:q` prompts if unsaved
+- [ ] Touch-only devices bypass vi mode (standard editing)
+
+## Constraints
+
+- No Visual mode in v1 (selection via standard touch/mouse)
+- No word motions (`w`, `b`, `e`) in v1
+- No search (`/`, `?`) in v1
+- No repeat command (`.`) in v1
+- Keyboard detection may not be 100% reliable; acceptable to have edge cases
+
+## Context
+
+- Research: `.lore/research/vi-mode-implementation.md`
+- Issue: #394
diff --git a/.lore/work/vi-mode-pair-writing.md b/.lore/work/vi-mode-pair-writing.md
new file mode 100644
index 00000000..653f66bf
--- /dev/null
+++ b/.lore/work/vi-mode-pair-writing.md
@@ -0,0 +1,222 @@
+# Work Breakdown: Vi Mode for Pair Writing
+
+Spec: `.lore/specs/vi-mode-pair-writing.md`
+Plan: `.lore/plans/vi-mode-pair-writing.md`
+
+## Chunks
+
+### 1. Configuration Layer
+**What**: Add `viMode` to VaultConfig and flow it through to frontend
+**Delivers**: Config toggle that can be read in PairWritingEditor (no behavior change yet)
+**Depends on**: Nothing
+
+Tasks:
+- Add `viMode?: boolean` to `VaultConfig` interface (backend)
+- Add `viMode?: boolean` to `VaultInfo` type (shared)
+- Add to `EditableVaultConfig` schema if user-editable, or just config file
+- Flow through vault discovery to API response
+- Verify config is accessible in `PairWritingMode` component
+
+### 2. Keyboard Detection
+**What**: Detect physical keyboard presence, gate vi mode on it
+**Delivers**: `useHasKeyboard` hook that returns boolean; vi mode disabled on touch-only
+**Depends on**: Nothing (can parallel with Chunk 1)
+
+Tasks:
+- Create `useHasKeyboard.ts` hook using matchMedia + maxTouchPoints
+- Add tests for detection logic (mock matchMedia)
+- Export from hooks index
+
+### 3. Vi Mode State Machine (Core)
+**What**: Mode state management (normal/insert/command) and transitions
+**Delivers**: `useViMode` hook with mode state, no commands yet
+**Depends on**: Nothing (can parallel)
+
+Tasks:
+- Create `useViMode.ts` with mode enum and state
+- Implement mode transitions: Normal↔Insert, Normal↔Command
+- Handle Esc key to return to Normal from any mode
+- Add `enabled` option to bypass when vi mode off
+- Unit tests for state transitions
+
+### 4. Cursor Overlay System
+**What**: Block cursor rendering using mirror element technique
+**Delivers**: `useViCursor` hook + `ViCursor` component showing cursor position
+**Depends on**: Chunk 3 (needs mode to toggle cursor style)
+
+Tasks:
+- Create `useViCursor.ts` with mirror element position calculation
+- Create `ViCursor.tsx` overlay component
+- Add `vi-mode.css` with cursor styles (block, blink animation)
+- Toggle `caret-color: transparent` based on mode
+- Handle scroll synchronization
+- Unit tests for position calculation
+
+### 5. Mode Indicator UI
+**What**: Display current mode ("-- NORMAL --", "-- INSERT --", etc.)
+**Delivers**: `ViModeIndicator` component visible in editor
+**Depends on**: Chunk 3 (needs mode state)
+
+Tasks:
+- Create `ViModeIndicator.tsx` component
+- Style to appear at bottom of editor (vim-style)
+- Show/hide based on vi mode enabled
+- Add to `vi-mode.css`
+
+### 6. Basic Movement Commands
+**What**: `h`, `j`, `k`, `l`, `0`, `$` cursor movement
+**Delivers**: Navigation works in Normal mode
+**Depends on**: Chunks 3, 4 (need mode + cursor)
+
+Tasks:
+- Add command dispatch to `useViMode`
+- Implement character movement (h, l) via selectionStart manipulation
+- Implement line movement (j, k) with line boundary detection
+- Implement line start/end (0, $)
+- Handle document boundaries (clamp, don't wrap)
+- Unit tests for each movement command
+
+### 7. Insert Mode Entry Commands
+**What**: `i`, `a`, `A`, `o`, `O` to enter Insert mode
+**Delivers**: Can enter Insert mode at various positions
+**Depends on**: Chunk 6 (builds on cursor manipulation)
+
+Tasks:
+- `i`: enter Insert at cursor
+- `a`: move right one, enter Insert
+- `A`: move to end of line, enter Insert
+- `o`: insert newline below, enter Insert
+- `O`: insert newline above, enter Insert
+- Unit tests for cursor position after each
+
+### 8. Undo Stack
+**What**: Internal undo history for `u` command
+**Delivers**: Can undo edit operations
+**Depends on**: Chunk 3 (needs to hook into content changes)
+
+Tasks:
+- Add undo stack to `useViMode` state
+- Push snapshot before edit operations
+- Implement `u` command to pop and restore
+- Batch Insert mode into single undo entry
+- Limit stack depth (100 entries)
+- Unit tests for undo behavior
+
+### 9. Delete Commands
+**What**: `x` (character) and `dd` (line) deletion
+**Delivers**: Can delete content in Normal mode
+**Depends on**: Chunks 6, 8 (movement + undo)
+
+Tasks:
+- Implement `x`: delete character at cursor
+- Implement `dd`: delete current line
+- Push to undo stack before delete
+- Update cursor position after delete
+- Handle edge cases (empty line, end of doc)
+- Unit tests
+
+### 10. Yank/Put Commands
+**What**: `yy`, `p`, `P` with internal clipboard
+**Delivers**: Copy/paste lines within editor
+**Depends on**: Chunks 6, 8, 9 (movement, undo, line operations)
+
+Tasks:
+- Add clipboard state to `useViMode`
+- Implement `yy`: copy current line to clipboard
+- Implement `p`: paste after cursor line
+- Implement `P`: paste before cursor line
+- Push to undo stack before paste
+- Unit tests
+
+### 11. Numeric Prefixes
+**What**: Count prefix for commands (e.g., `5j`, `3dd`)
+**Delivers**: Commands can be repeated with count
+**Depends on**: Chunks 6, 9, 10 (needs commands to prefix)
+
+Tasks:
+- Add `pendingCount` state to `useViMode`
+- Accumulate digits before command
+- Handle `0` special case (command vs. digit)
+- Pass count to command execution
+- Clear count after command or Esc
+- Unit tests for count accumulation and execution
+
+### 12. Command Mode UI
+**What**: `:` command input field and parsing
+**Delivers**: Can type ex commands
+**Depends on**: Chunk 3 (needs Command mode state)
+
+Tasks:
+- Create `ViCommandLine.tsx` component
+- Render at bottom of editor when mode is 'command'
+- Capture input until Enter or Esc
+- Focus management (focus input on enter, return on exit)
+- Add to `vi-mode.css`
+
+### 13. Ex Commands
+**What**: `:w`, `:wq`, `:q`, `:q!` implementation
+**Delivers**: Can save and exit via commands
+**Depends on**: Chunk 12 (needs command input)
+
+Tasks:
+- Parse command string in `useViMode`
+- `:w` → call `onSave` callback
+- `:wq` → call `onSave` then `onExit`
+- `:q` → check unsaved, call `onExit` or `onQuitWithUnsaved`
+- `:q!` → call `onExit` (discard)
+- Handle unknown commands (no-op or error indicator)
+- Unit tests for each command
+
+### 14. Integration
+**What**: Wire everything into `PairWritingEditor`
+**Delivers**: Full vi mode working in Pair Writing
+**Depends on**: All previous chunks
+
+Tasks:
+- Integrate `useViMode` into `PairWritingEditor`
+- Integrate `useViCursor` and `ViCursor`
+- Add `ViModeIndicator` and `ViCommandLine`
+- Pass callbacks (onSave, onExit, onQuitWithUnsaved)
+- Gate on `viMode` config + `hasKeyboard`
+- Integration tests
+
+### 15. Polish and Edge Cases
+**What**: Handle remaining edge cases from review
+**Delivers**: Robust implementation
+**Depends on**: Chunk 14
+
+Tasks:
+- Esc in Normal mode cancels pending operator/count
+- Cursor boundary clamping (h at start, l at end)
+- `dd` on empty document
+- `p` with empty clipboard
+- Interaction with `isProcessingQuickAction` state
+- Manual testing on various content sizes
+
+## Suggested Order
+
+1. **Chunk 1** (Configuration) - Foundation, quick win
+2. **Chunk 2** (Keyboard Detection) - Can parallel with 1
+3. **Chunk 3** (State Machine) - Core architecture, can parallel
+4. **Chunk 4** (Cursor Overlay) - Visual feedback, depends on 3
+5. **Chunk 5** (Mode Indicator) - Quick UI win, depends on 3
+6. **Chunk 6** (Basic Movement) - First real vi behavior
+7. **Chunk 7** (Insert Entry) - Can now edit text
+8. **Chunk 8** (Undo Stack) - Safety net before destructive commands
+9. **Chunk 9** (Delete) - First destructive command
+10. **Chunk 10** (Yank/Put) - Copy/paste
+11. **Chunk 11** (Numeric Prefixes) - Power user feature
+12. **Chunk 12** (Command Mode UI) - Needed for ex commands
+13. **Chunk 13** (Ex Commands) - Save/exit flow
+14. **Chunk 14** (Integration) - Wire it all together
+15. **Chunk 15** (Polish) - Edge cases and hardening
+
+## Release Points
+
+**Milestone A (Chunks 1-5)**: Vi mode toggle works, shows mode indicator and block cursor. No commands yet, but visual infrastructure complete.
+
+**Milestone B (Chunks 6-7)**: Navigation and insert mode work. Can move around and type text.
+
+**Milestone C (Chunks 8-11)**: Full Normal mode editing. Delete, yank, put, undo, counts all work.
+
+**Milestone D (Chunks 12-15)**: Complete feature. Ex commands for save/exit, full integration, polished.
diff --git a/backend/src/__tests__/meeting-capture.test.ts b/backend/src/__tests__/meeting-capture.test.ts
index 00488c9a..5e15bf7b 100644
--- a/backend/src/__tests__/meeting-capture.test.ts
+++ b/backend/src/__tests__/meeting-capture.test.ts
@@ -195,6 +195,7 @@ describe("getMeetingsDirectory", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const dir = getMeetingsDirectory(vault);
expect(dir).toBe("/vaults/test/00_Inbox/meetings");
@@ -217,6 +218,7 @@ describe("getMeetingsDirectory", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const dir = getMeetingsDirectory(vault);
expect(dir).toBe("/vaults/custom/Custom/Inbox/meetings");
@@ -380,6 +382,7 @@ describe("startMeeting Integration", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
});
@@ -493,6 +496,7 @@ describe("captureToMeeting Integration", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
// Start a meeting to get active meeting state
@@ -619,6 +623,7 @@ describe("stopMeeting Integration", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
});
@@ -724,6 +729,7 @@ describe("Edge Cases", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
});
diff --git a/backend/src/__tests__/note-capture.test.ts b/backend/src/__tests__/note-capture.test.ts
index af990365..3b61f6a1 100644
--- a/backend/src/__tests__/note-capture.test.ts
+++ b/backend/src/__tests__/note-capture.test.ts
@@ -581,6 +581,7 @@ describe("captureToDaily Integration", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
});
@@ -812,6 +813,7 @@ describe("Edge Cases", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
});
diff --git a/backend/src/__tests__/session-manager.test.ts b/backend/src/__tests__/session-manager.test.ts
index abd1b88e..8d7bd60f 100644
--- a/backend/src/__tests__/session-manager.test.ts
+++ b/backend/src/__tests__/session-manager.test.ts
@@ -70,6 +70,7 @@ function createMockVault(overrides: Partial = {}): VaultInfo {
...overrides,
order: overrides.order ?? 999999,
cardsEnabled: overrides.cardsEnabled ?? true,
+ viMode: overrides.viMode ?? false,
};
}
diff --git a/backend/src/__tests__/test-helpers.ts b/backend/src/__tests__/test-helpers.ts
index 21a36523..9a85edbe 100644
--- a/backend/src/__tests__/test-helpers.ts
+++ b/backend/src/__tests__/test-helpers.ts
@@ -34,6 +34,8 @@ export function createMockVault(overrides: Partial = {}): VaultInfo {
order: overrides.order ?? 999999,
// Ensure cardsEnabled is always a boolean
cardsEnabled: overrides.cardsEnabled ?? true,
+ // Ensure viMode is always a boolean
+ viMode: overrides.viMode ?? false,
};
}
diff --git a/backend/src/__tests__/transcript-manager.test.ts b/backend/src/__tests__/transcript-manager.test.ts
index 0886a8b5..836e30b9 100644
--- a/backend/src/__tests__/transcript-manager.test.ts
+++ b/backend/src/__tests__/transcript-manager.test.ts
@@ -324,6 +324,7 @@ describe("getTranscriptsDirectory", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const dir = getTranscriptsDirectory(vault);
expect(dir).toBe("/vaults/test-vault/00_Inbox/chats");
@@ -346,6 +347,7 @@ describe("getTranscriptsDirectory", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const dir = getTranscriptsDirectory(vault);
expect(dir).toBe("/vaults/test-vault/Custom/Inbox/chats");
@@ -368,6 +370,7 @@ describe("getTranscriptsDirectory", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const dir = getTranscriptsDirectory(vault);
expect(dir).toBe("/vaults/test-vault/content/00_Inbox/chats");
@@ -428,6 +431,7 @@ describe("Transcript Integration", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
});
diff --git a/backend/src/__tests__/vault-config.test.ts b/backend/src/__tests__/vault-config.test.ts
index 86e7d642..1212bd30 100644
--- a/backend/src/__tests__/vault-config.test.ts
+++ b/backend/src/__tests__/vault-config.test.ts
@@ -20,6 +20,7 @@ import {
DEFAULT_QUOTES_PER_WEEK,
DEFAULT_DISCUSSION_MODEL,
DEFAULT_CARDS_ENABLED,
+ DEFAULT_VI_MODE,
VALID_DISCUSSION_MODELS,
loadVaultConfig,
loadSlashCommands,
@@ -37,6 +38,7 @@ import {
resolvePinnedAssets,
resolveDiscussionModel,
resolveCardsEnabled,
+ resolveViMode,
saveSlashCommands,
savePinnedAssets,
saveVaultConfig,
@@ -124,6 +126,12 @@ describe("vault-config", () => {
});
});
+ describe("DEFAULT_VI_MODE", () => {
+ test("exports expected default vi mode value", () => {
+ expect(DEFAULT_VI_MODE).toBe(false);
+ });
+ });
+
describe("loadVaultConfig", () => {
test("returns empty object when no config file exists", async () => {
const config = await loadVaultConfig(testDir);
@@ -856,6 +864,84 @@ describe("vault-config", () => {
});
});
+ describe("loadVaultConfig with viMode", () => {
+ test("loads config with viMode true", async () => {
+ await writeFile(
+ join(testDir, CONFIG_FILE_NAME),
+ JSON.stringify({ viMode: true })
+ );
+
+ const config = await loadVaultConfig(testDir);
+ expect(config.viMode).toBe(true);
+ });
+
+ test("loads config with viMode false", async () => {
+ await writeFile(
+ join(testDir, CONFIG_FILE_NAME),
+ JSON.stringify({ viMode: false })
+ );
+
+ const config = await loadVaultConfig(testDir);
+ expect(config.viMode).toBe(false);
+ });
+
+ test("ignores non-boolean viMode value", async () => {
+ await writeFile(
+ join(testDir, CONFIG_FILE_NAME),
+ JSON.stringify({ viMode: "true" })
+ );
+
+ const config = await loadVaultConfig(testDir);
+ expect(config.viMode).toBeUndefined();
+ });
+
+ test("ignores numeric viMode value", async () => {
+ await writeFile(
+ join(testDir, CONFIG_FILE_NAME),
+ JSON.stringify({ viMode: 1 })
+ );
+
+ const config = await loadVaultConfig(testDir);
+ expect(config.viMode).toBeUndefined();
+ });
+
+ test("loads viMode alongside other fields", async () => {
+ await writeFile(
+ join(testDir, CONFIG_FILE_NAME),
+ JSON.stringify({
+ contentRoot: "content",
+ viMode: true,
+ })
+ );
+
+ const config = await loadVaultConfig(testDir);
+ expect(config.contentRoot).toBe("content");
+ expect(config.viMode).toBe(true);
+ });
+ });
+
+ describe("resolveViMode", () => {
+ test("returns default (false) when viMode not configured", () => {
+ const result = resolveViMode({});
+ expect(result).toBe(false);
+ });
+
+ test("returns default (false) when viMode is undefined", () => {
+ const result = resolveViMode({ viMode: undefined });
+ expect(result).toBe(false);
+ });
+
+ test("returns true when viMode is true", () => {
+ const result = resolveViMode({ viMode: true });
+ expect(result).toBe(true);
+ });
+
+ test("returns false when viMode is false", () => {
+ const result = resolveViMode({ viMode: false });
+ expect(result).toBe(false);
+ });
+ });
+
describe("loadSlashCommands", () => {
test("returns undefined when cache file does not exist", async () => {
const commands = await loadSlashCommands(testDir);
@@ -1778,5 +1864,60 @@ describe("vault-config", () => {
expect(parsed.cardsEnabled).toBe(true);
expect(parsed.title).toBe("Vault");
});
+
+ test("saves viMode field correctly", async () => {
+ const editableConfig: EditableVaultConfig = {
+ title: "Test Vault",
+ viMode: true,
+ };
+ const result = await saveVaultConfig(testDir, editableConfig);
+
+ expect(result).toEqual({ success: true });
+
+ const content = await readFile(join(testDir, CONFIG_FILE_NAME), "utf-8");
+ const parsed = JSON.parse(content) as Record;
+ expect(parsed.title).toBe("Test Vault");
+ expect(parsed.viMode).toBe(true);
+ });
+
+ test("preserves viMode true when updating other fields", async () => {
+ // Create existing config with viMode
+ await writeFile(
+ join(testDir, CONFIG_FILE_NAME),
+ JSON.stringify({ viMode: true })
+ );
+
+ const editableConfig: EditableVaultConfig = {
+ title: "New Title",
+ };
+ const result = await saveVaultConfig(testDir, editableConfig);
+
+ expect(result).toEqual({ success: true });
+
+ const content = await readFile(join(testDir, CONFIG_FILE_NAME), "utf-8");
+ const parsed = JSON.parse(content) as Record;
+ expect(parsed.title).toBe("New Title");
+ expect(parsed.viMode).toBe(true);
+ });
+
+ test("updates viMode when explicitly set", async () => {
+ // Create existing config with viMode true
+ await writeFile(
+ join(testDir, CONFIG_FILE_NAME),
+ JSON.stringify({ viMode: true, title: "Vault" })
+ );
+
+ const editableConfig: EditableVaultConfig = {
+ viMode: false,
+ };
+ const result = await saveVaultConfig(testDir, editableConfig);
+
+ expect(result).toEqual({ success: true });
+
+ const content = await readFile(join(testDir, CONFIG_FILE_NAME), "utf-8");
+ const parsed = JSON.parse(content) as Record;
+ expect(parsed.viMode).toBe(false);
+ expect(parsed.title).toBe("Vault");
+ });
});
});
diff --git a/backend/src/__tests__/vault-manager.test.ts b/backend/src/__tests__/vault-manager.test.ts
index 49957804..5d7b3e55 100644
--- a/backend/src/__tests__/vault-manager.test.ts
+++ b/backend/src/__tests__/vault-manager.test.ts
@@ -662,6 +662,7 @@ describe("Filesystem Integration", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
expect(getVaultInboxPath(vault)).toBe("/vaults/test-vault/00_Inbox");
@@ -684,6 +685,7 @@ describe("Filesystem Integration", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
expect(getVaultInboxPath(vault)).toBe("/vaults/test-vault/Custom/Inbox");
@@ -706,6 +708,7 @@ describe("Filesystem Integration", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
expect(getVaultInboxPath(vault)).toBe("/vaults/test-vault/content/00_Inbox");
@@ -898,6 +901,7 @@ describe("Goals Feature", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const goals = await getVaultGoals(vault);
@@ -933,6 +937,7 @@ describe("Goals Feature", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const content = await getVaultGoals(vault);
@@ -957,6 +962,7 @@ describe("Goals Feature", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const goals = await getVaultGoals(vault);
diff --git a/backend/src/__tests__/vault-resolution.test.ts b/backend/src/__tests__/vault-resolution.test.ts
index 2fe776ab..35cc0561 100644
--- a/backend/src/__tests__/vault-resolution.test.ts
+++ b/backend/src/__tests__/vault-resolution.test.ts
@@ -355,6 +355,7 @@ describe("getVaultFromContext", () => {
badges: [],
order: Infinity,
cardsEnabled: true,
+ viMode: false,
};
c.set("vault", mockVault);
diff --git a/backend/src/__tests__/websocket-handler.test.ts b/backend/src/__tests__/websocket-handler.test.ts
index 07f8f66d..9072d583 100644
--- a/backend/src/__tests__/websocket-handler.test.ts
+++ b/backend/src/__tests__/websocket-handler.test.ts
@@ -322,6 +322,7 @@ function createMockVault(overrides: Partial = {}): VaultInfo {
...overrides,
order: overrides.order ?? 999999,
cardsEnabled: overrides.cardsEnabled ?? true,
+ viMode: overrides.viMode ?? false,
};
}
diff --git a/backend/src/handlers/__tests__/pair-writing-handlers.test.ts b/backend/src/handlers/__tests__/pair-writing-handlers.test.ts
index 5d335ef5..3deef1b6 100644
--- a/backend/src/handlers/__tests__/pair-writing-handlers.test.ts
+++ b/backend/src/handlers/__tests__/pair-writing-handlers.test.ts
@@ -52,6 +52,7 @@ beforeEach(async () => {
badges: [],
order: 0,
cardsEnabled: true,
+ viMode: false,
};
sentMessages = [];
sentErrors = [];
diff --git a/backend/src/vault-config.ts b/backend/src/vault-config.ts
index 98cd81d7..84bb3619 100644
--- a/backend/src/vault-config.ts
+++ b/backend/src/vault-config.ts
@@ -150,6 +150,13 @@ export interface VaultConfig {
* Default: true
*/
cardsEnabled?: boolean;
+
+ /**
+ * Whether vi mode is enabled for the Pair Writing editor.
+ * When true, the editor uses vi-style keybindings.
+ * Default: false
+ */
+ viMode?: boolean;
}
/**
@@ -225,6 +232,12 @@ export const DEFAULT_ORDER = 999999;
*/
export const DEFAULT_CARDS_ENABLED = true;
+/**
+ * Default value for vi mode in Pair Writing editor.
+ * When false (default), the editor uses standard keybindings.
+ */
+export const DEFAULT_VI_MODE = false;
+
/**
* Valid badge color names.
*/
@@ -337,6 +350,11 @@ export async function loadVaultConfig(vaultPath: string): Promise {
config.cardsEnabled = obj.cardsEnabled;
}
+ // Validate viMode (must be a boolean)
+ if (typeof obj.viMode === "boolean") {
+ config.viMode = obj.viMode;
+ }
+
// Validate badges array
if (Array.isArray(obj.badges)) {
config.badges = obj.badges.filter(
@@ -569,6 +587,16 @@ export function resolveCardsEnabled(config: VaultConfig): boolean {
return config.cardsEnabled ?? DEFAULT_CARDS_ENABLED;
}
+/**
+ * Resolves whether vi mode is enabled for Pair Writing editor.
+ *
+ * @param config - Vault configuration
+ * @returns true if vi mode is enabled (default: false)
+ */
+export function resolveViMode(config: VaultConfig): boolean {
+ return config.viMode ?? DEFAULT_VI_MODE;
+}
+
/**
* Saves pinned assets to the vault configuration file.
* Preserves existing configuration fields while updating pinnedAssets.
@@ -624,7 +652,8 @@ function isAllDefaults(config: EditableVaultConfig): boolean {
config.recentDiscussions === undefined &&
(config.badges === undefined || config.badges.length === 0) &&
config.order === undefined &&
- config.cardsEnabled === undefined
+ config.cardsEnabled === undefined &&
+ config.viMode === undefined
);
}
@@ -707,6 +736,9 @@ export async function saveVaultConfig(
if (editableConfig.cardsEnabled !== undefined) {
mergedConfig.cardsEnabled = editableConfig.cardsEnabled;
}
+ if (editableConfig.viMode !== undefined) {
+ mergedConfig.viMode = editableConfig.viMode;
+ }
// Write merged config back to file
await writeFile(configPath, JSON.stringify(mergedConfig, null, 2) + "\n", "utf-8");
diff --git a/backend/src/vault-manager.ts b/backend/src/vault-manager.ts
index 2fb084ac..95cebf08 100644
--- a/backend/src/vault-manager.ts
+++ b/backend/src/vault-manager.ts
@@ -25,6 +25,7 @@ import {
resolveBadges,
resolveOrder,
resolveCardsEnabled,
+ resolveViMode,
type VaultConfig,
} from "./vault-config";
@@ -343,6 +344,7 @@ export async function parseVault(
badges: resolveBadges(config),
order: resolveOrder(config),
cardsEnabled: resolveCardsEnabled(config),
+ viMode: resolveViMode(config),
};
}
diff --git a/frontend/src/components/browse/__tests__/BrowseMode.test.tsx b/frontend/src/components/browse/__tests__/BrowseMode.test.tsx
index 557873de..d002961c 100644
--- a/frontend/src/components/browse/__tests__/BrowseMode.test.tsx
+++ b/frontend/src/components/browse/__tests__/BrowseMode.test.tsx
@@ -68,6 +68,7 @@ const testVault: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const mockDirectoryEntries: FileEntry[] = [
diff --git a/frontend/src/components/capture/__tests__/NoteCapture.test.tsx b/frontend/src/components/capture/__tests__/NoteCapture.test.tsx
index 1042ae83..9823f4b3 100644
--- a/frontend/src/components/capture/__tests__/NoteCapture.test.tsx
+++ b/frontend/src/components/capture/__tests__/NoteCapture.test.tsx
@@ -28,6 +28,7 @@ const testVault: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
// Mock matchMedia for touch device detection
diff --git a/frontend/src/components/discussion/__tests__/Discussion.test.tsx b/frontend/src/components/discussion/__tests__/Discussion.test.tsx
index aeb8b297..b4f1d9f4 100644
--- a/frontend/src/components/discussion/__tests__/Discussion.test.tsx
+++ b/frontend/src/components/discussion/__tests__/Discussion.test.tsx
@@ -80,6 +80,7 @@ const testVault: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
function TestWrapper({ children }: { children: ReactNode }) {
diff --git a/frontend/src/components/home/__tests__/HealthPanel.test.tsx b/frontend/src/components/home/__tests__/HealthPanel.test.tsx
index fac723de..3cee9c08 100644
--- a/frontend/src/components/home/__tests__/HealthPanel.test.tsx
+++ b/frontend/src/components/home/__tests__/HealthPanel.test.tsx
@@ -66,6 +66,7 @@ const testVault: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
beforeEach(() => {
diff --git a/frontend/src/components/home/__tests__/HomeView.test.tsx b/frontend/src/components/home/__tests__/HomeView.test.tsx
index 6ef53193..00c60c0f 100644
--- a/frontend/src/components/home/__tests__/HomeView.test.tsx
+++ b/frontend/src/components/home/__tests__/HomeView.test.tsx
@@ -267,6 +267,7 @@ const testVault: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
/**
diff --git a/frontend/src/components/home/__tests__/InspirationCard.test.tsx b/frontend/src/components/home/__tests__/InspirationCard.test.tsx
index 30047d8c..d1b1b2af 100644
--- a/frontend/src/components/home/__tests__/InspirationCard.test.tsx
+++ b/frontend/src/components/home/__tests__/InspirationCard.test.tsx
@@ -28,6 +28,7 @@ const testVault: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const mockContextual: InspirationItem = {
diff --git a/frontend/src/components/home/__tests__/RecentActivity.test.tsx b/frontend/src/components/home/__tests__/RecentActivity.test.tsx
index d36a844a..0e5b5b4d 100644
--- a/frontend/src/components/home/__tests__/RecentActivity.test.tsx
+++ b/frontend/src/components/home/__tests__/RecentActivity.test.tsx
@@ -39,6 +39,7 @@ const testVault: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const mockCaptures: RecentNoteEntry[] = [
diff --git a/frontend/src/components/pair-writing/ViCursor.tsx b/frontend/src/components/pair-writing/ViCursor.tsx
new file mode 100644
index 00000000..2e907b21
--- /dev/null
+++ b/frontend/src/components/pair-writing/ViCursor.tsx
@@ -0,0 +1,64 @@
+/**
+ * ViCursor Component - Block Cursor Overlay for Vi Mode
+ *
+ * Renders a block cursor overlay for vi normal mode. The cursor is positioned
+ * absolutely within the editor container, showing the current cursor position
+ * with a semi-transparent background and blink animation.
+ *
+ * @see .lore/plans/vi-mode-pair-writing.md (TD-10)
+ */
+
+import type { CSSProperties } from "react";
+import "./vi-mode.css";
+
+export interface ViCursorProps {
+ /** CSS style containing top, left, height from useViCursor */
+ style: CSSProperties;
+ /** Whether to show the cursor (visible in normal/command mode) */
+ visible: boolean;
+}
+
+/**
+ * Block cursor overlay for vi normal mode.
+ *
+ * This component renders on top of the textarea to show a vim-style block
+ * cursor when in normal mode. The native textarea caret is hidden via CSS
+ * (caret-color: transparent) when this cursor is shown.
+ *
+ * Features:
+ * - Block cursor appearance (~0.6em wide)
+ * - Semi-transparent background
+ * - Blink animation
+ * - pointer-events: none so it doesn't interfere with textarea interaction
+ *
+ * @example
+ * ```tsx
+ * const { cursorStyle, showOverlay } = useViCursor({
+ * textareaRef,
+ * cursorPosition,
+ * mode: viMode,
+ * enabled: viModeEnabled,
+ * });
+ *
+ * return (
+ *
+ *
+ *
+ *
+ * );
+ * ```
+ */
+export function ViCursor({ style, visible }: ViCursorProps): React.ReactNode {
+ if (!visible) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/pair-writing/ViModeIndicator.tsx b/frontend/src/components/pair-writing/ViModeIndicator.tsx
new file mode 100644
index 00000000..3c391c88
--- /dev/null
+++ b/frontend/src/components/pair-writing/ViModeIndicator.tsx
@@ -0,0 +1,59 @@
+/**
+ * ViModeIndicator Component
+ *
+ * Displays the current vi mode in vim-style format at the bottom of the editor.
+ * Shows "-- NORMAL --", "-- INSERT --", or "-- COMMAND --" based on mode.
+ *
+ * @see .lore/specs/vi-mode-pair-writing.md REQ-5
+ */
+
+import "./vi-mode.css";
+
+export type ViMode = "normal" | "insert" | "command";
+
+export interface ViModeIndicatorProps {
+ /** Current vi mode */
+ mode: ViMode;
+ /** Whether vi mode is enabled (hidden when false) */
+ visible: boolean;
+ /** Command buffer to display in command mode (e.g., ":w") */
+ commandBuffer?: string;
+}
+
+/** Map mode to display text */
+const MODE_LABELS: Record = {
+ normal: "-- NORMAL --",
+ insert: "-- INSERT --",
+ command: "-- COMMAND --",
+};
+
+/**
+ * Mode indicator component showing current vi mode.
+ * Positioned at bottom-left of editor, styled like classic vim.
+ */
+export function ViModeIndicator({
+ mode,
+ visible,
+ commandBuffer,
+}: ViModeIndicatorProps): React.ReactNode {
+ if (!visible) {
+ return null;
+ }
+
+ const label = MODE_LABELS[mode];
+ const displayText =
+ mode === "command" && commandBuffer
+ ? `${label} ${commandBuffer}`
+ : label;
+
+ return (
+
+ {displayText}
+
+ );
+}
diff --git a/frontend/src/components/pair-writing/__tests__/ViModeIndicator.test.tsx b/frontend/src/components/pair-writing/__tests__/ViModeIndicator.test.tsx
new file mode 100644
index 00000000..1b06b811
--- /dev/null
+++ b/frontend/src/components/pair-writing/__tests__/ViModeIndicator.test.tsx
@@ -0,0 +1,188 @@
+/**
+ * Tests for ViModeIndicator component
+ *
+ * Tests cover:
+ * - Rendering correct mode labels
+ * - Visibility toggling
+ * - Command buffer display in command mode
+ * - Accessibility attributes
+ *
+ * @see .lore/specs/vi-mode-pair-writing.md REQ-5
+ */
+
+import { describe, it, expect, afterEach } from "bun:test";
+import { render, screen, cleanup } from "@testing-library/react";
+import { ViModeIndicator, type ViMode } from "../ViModeIndicator";
+
+afterEach(() => {
+ cleanup();
+});
+
+// =============================================================================
+// Mode Label Tests
+// =============================================================================
+
+describe("ViModeIndicator - mode labels", () => {
+ it("renders '-- NORMAL --' when mode is normal", () => {
+ render();
+
+ expect(screen.getByText("-- NORMAL --")).toBeDefined();
+ });
+
+ it("renders '-- INSERT --' when mode is insert", () => {
+ render();
+
+ expect(screen.getByText("-- INSERT --")).toBeDefined();
+ });
+
+ it("renders '-- COMMAND --' when mode is command", () => {
+ render();
+
+ expect(screen.getByText("-- COMMAND --")).toBeDefined();
+ });
+
+ it("sets data-mode attribute for CSS styling", () => {
+ const modes: ViMode[] = ["normal", "insert", "command"];
+
+ for (const mode of modes) {
+ cleanup();
+ render();
+
+ const indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator?.getAttribute("data-mode")).toBe(mode);
+ }
+ });
+});
+
+// =============================================================================
+// Visibility Tests
+// =============================================================================
+
+describe("ViModeIndicator - visibility", () => {
+ it("renders when visible is true", () => {
+ render();
+
+ const indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator).not.toBeNull();
+ });
+
+ it("does not render when visible is false", () => {
+ render();
+
+ const indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator).toBeNull();
+ });
+
+ it("returns null (not hidden element) when not visible", () => {
+ const { container } = render(
+
+ );
+
+ // Container should be empty when not visible
+ expect(container.firstChild).toBeNull();
+ });
+});
+
+// =============================================================================
+// Command Buffer Tests
+// =============================================================================
+
+describe("ViModeIndicator - command buffer", () => {
+ it("shows commandBuffer in command mode", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("-- COMMAND -- :w")).toBeDefined();
+ });
+
+ it("shows full command buffer with longer commands", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("-- COMMAND -- :wq")).toBeDefined();
+ });
+
+ it("ignores commandBuffer in normal mode", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("-- NORMAL --")).toBeDefined();
+ expect(screen.queryByText(":w")).toBeNull();
+ });
+
+ it("ignores commandBuffer in insert mode", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("-- INSERT --")).toBeDefined();
+ expect(screen.queryByText(":w")).toBeNull();
+ });
+
+ it("shows only mode label when commandBuffer is empty string", () => {
+ render();
+
+ expect(screen.getByText("-- COMMAND --")).toBeDefined();
+ });
+
+ it("shows only mode label when commandBuffer is undefined", () => {
+ render();
+
+ expect(screen.getByText("-- COMMAND --")).toBeDefined();
+ });
+});
+
+// =============================================================================
+// Accessibility Tests
+// =============================================================================
+
+describe("ViModeIndicator - accessibility", () => {
+ it("has aria-live attribute for screen readers", () => {
+ render();
+
+ const indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator?.getAttribute("aria-live")).toBe("polite");
+ });
+
+ it("has descriptive aria-label", () => {
+ render();
+
+ const indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator?.getAttribute("aria-label")).toBe("Vi mode: normal");
+ });
+
+ it("updates aria-label based on mode", () => {
+ const { rerender } = render(
+
+ );
+
+ let indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator?.getAttribute("aria-label")).toBe("Vi mode: normal");
+
+ rerender();
+
+ indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator?.getAttribute("aria-label")).toBe("Vi mode: insert");
+
+ rerender();
+
+ indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator?.getAttribute("aria-label")).toBe("Vi mode: command");
+ });
+});
+
+// =============================================================================
+// CSS Class Tests
+// =============================================================================
+
+describe("ViModeIndicator - CSS classes", () => {
+ it("has vi-mode-indicator class", () => {
+ render();
+
+ const indicator = document.querySelector(".vi-mode-indicator");
+ expect(indicator).not.toBeNull();
+ });
+});
diff --git a/frontend/src/components/pair-writing/index.ts b/frontend/src/components/pair-writing/index.ts
index fdd72c59..205bb3f0 100644
--- a/frontend/src/components/pair-writing/index.ts
+++ b/frontend/src/components/pair-writing/index.ts
@@ -5,3 +5,4 @@
export { PairWritingMode, type PairWritingModeProps } from "./PairWritingMode";
export { PairWritingEditor, type PairWritingEditorProps } from "./PairWritingEditor";
export { PairWritingToolbar, type PairWritingToolbarProps } from "./PairWritingToolbar";
+export { ViModeIndicator, type ViModeIndicatorProps, type ViMode } from "./ViModeIndicator";
diff --git a/frontend/src/components/pair-writing/vi-mode.css b/frontend/src/components/pair-writing/vi-mode.css
new file mode 100644
index 00000000..465d59ae
--- /dev/null
+++ b/frontend/src/components/pair-writing/vi-mode.css
@@ -0,0 +1,75 @@
+/**
+ * Vi Mode Styles
+ *
+ * Styles for vi mode UI elements: block cursor overlay, mode indicator,
+ * and command line.
+ *
+ * @see .lore/plans/vi-mode-pair-writing.md (TD-10)
+ */
+
+/* Hide native caret in normal mode */
+.pair-writing-editor__textarea--vi-normal {
+ caret-color: transparent;
+}
+
+/* Hide native caret in command mode (cursor stays visible) */
+.pair-writing-editor__textarea--vi-command {
+ caret-color: transparent;
+}
+
+/* Block cursor overlay */
+.vi-cursor {
+ position: absolute;
+ pointer-events: none;
+ background-color: var(--color-text, #e0e0e0);
+ opacity: 0.7;
+ width: 0.6em;
+ animation: vi-cursor-blink 1s step-end infinite;
+ z-index: 5;
+}
+
+/* Blink animation for block cursor */
+@keyframes vi-cursor-blink {
+ 0%,
+ 100% {
+ opacity: 0.7;
+ }
+ 50% {
+ opacity: 0;
+ }
+}
+
+/* Disable blink when textarea is not focused */
+.pair-writing-editor:not(:focus-within) .vi-cursor {
+ animation: none;
+ opacity: 0.4;
+}
+
+/* ============================================
+ Mode Indicator
+
+ Displays current mode at bottom-left of editor.
+ Vim-style appearance: monospace, bold, subtle.
+ ============================================ */
+
+.vi-mode-indicator {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ font-family: var(--font-mono);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-secondary);
+ pointer-events: none;
+ user-select: none;
+ z-index: 5;
+}
+
+/* Mode-specific styling for visual distinction */
+.vi-mode-indicator[data-mode="insert"] {
+ color: var(--color-success);
+}
+
+.vi-mode-indicator[data-mode="command"] {
+ color: var(--color-accent-quartary);
+}
diff --git a/frontend/src/components/vault/__tests__/VaultSelect.test.tsx b/frontend/src/components/vault/__tests__/VaultSelect.test.tsx
index f62e0f1c..6b216af5 100644
--- a/frontend/src/components/vault/__tests__/VaultSelect.test.tsx
+++ b/frontend/src/components/vault/__tests__/VaultSelect.test.tsx
@@ -69,6 +69,7 @@ const testVaults: VaultInfo[] = [
badges: [{ text: "Primary", color: "blue" }],
order: 1,
cardsEnabled: true,
+ viMode: false,
},
{
id: "vault-2",
@@ -87,6 +88,7 @@ const testVaults: VaultInfo[] = [
badges: [],
order: 2,
cardsEnabled: true,
+ viMode: false,
},
];
@@ -783,6 +785,7 @@ describe("VaultSelect", () => {
badges: [],
order: 999,
cardsEnabled: true,
+ viMode: false,
};
wsInstances[0].simulateMessage({
diff --git a/frontend/src/contexts/__tests__/SessionContext.test.tsx b/frontend/src/contexts/__tests__/SessionContext.test.tsx
index 3892c888..39892de1 100644
--- a/frontend/src/contexts/__tests__/SessionContext.test.tsx
+++ b/frontend/src/contexts/__tests__/SessionContext.test.tsx
@@ -50,6 +50,7 @@ const testVault: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
const testVault2: VaultInfo = {
@@ -68,6 +69,7 @@ const testVault2: VaultInfo = {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
};
describe("SessionContext", () => {
diff --git a/frontend/src/contexts/session/__tests__/reducer-update-config.test.ts b/frontend/src/contexts/session/__tests__/reducer-update-config.test.ts
index 1bf2aec1..249df61a 100644
--- a/frontend/src/contexts/session/__tests__/reducer-update-config.test.ts
+++ b/frontend/src/contexts/session/__tests__/reducer-update-config.test.ts
@@ -29,6 +29,7 @@ const mockVault: VaultInfo = {
badges: [],
order: 1,
cardsEnabled: false, // Start with false
+ viMode: false,
};
describe("UPDATE_VAULT_CONFIG action", () => {
diff --git a/frontend/src/hooks/__tests__/useViCursor.test.ts b/frontend/src/hooks/__tests__/useViCursor.test.ts
new file mode 100644
index 00000000..f4a953bf
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useViCursor.test.ts
@@ -0,0 +1,346 @@
+/**
+ * useViCursor Hook Tests
+ *
+ * Tests the vi mode block cursor position calculation and visibility.
+ *
+ * @see .lore/plans/vi-mode-pair-writing.md (TD-10)
+ */
+
+import { describe, it, expect, afterEach } from "bun:test";
+import { renderHook } from "@testing-library/react";
+import { useViCursor, calculateCursorPosition, type UseViCursorOptions } from "../useViCursor";
+import { createRef } from "react";
+
+// Mock textarea for testing
+function createMockTextarea(options: {
+ value?: string;
+ scrollTop?: number;
+ scrollLeft?: number;
+}): HTMLTextAreaElement {
+ const textarea = document.createElement("textarea");
+ textarea.value = options.value ?? "";
+ textarea.scrollTop = options.scrollTop ?? 0;
+ textarea.scrollLeft = options.scrollLeft ?? 0;
+
+ // Set up basic styling that affects cursor position
+ textarea.style.fontFamily = "monospace";
+ textarea.style.fontSize = "14px";
+ textarea.style.lineHeight = "20px";
+ textarea.style.padding = "10px";
+ textarea.style.width = "300px";
+ textarea.style.whiteSpace = "pre-wrap";
+ textarea.style.wordWrap = "break-word";
+ textarea.style.boxSizing = "border-box";
+
+ // Append to DOM so getBoundingClientRect works
+ document.body.appendChild(textarea);
+
+ return textarea;
+}
+
+describe("useViCursor", () => {
+ let textarea: HTMLTextAreaElement | null = null;
+
+ afterEach(() => {
+ if (textarea && textarea.parentNode) {
+ document.body.removeChild(textarea);
+ }
+ textarea = null;
+ });
+
+ describe("showOverlay visibility", () => {
+ it("shows overlay in normal mode when enabled", () => {
+ textarea = createMockTextarea({ value: "hello" });
+ const ref = createRef();
+ (ref as { current: HTMLTextAreaElement }).current = textarea;
+
+ const { result } = renderHook(() =>
+ useViCursor({
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "normal",
+ enabled: true,
+ })
+ );
+
+ expect(result.current.showOverlay).toBe(true);
+ });
+
+ it("shows overlay in command mode when enabled", () => {
+ textarea = createMockTextarea({ value: "hello" });
+ const ref = createRef();
+ (ref as { current: HTMLTextAreaElement }).current = textarea;
+
+ const { result } = renderHook(() =>
+ useViCursor({
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "command",
+ enabled: true,
+ })
+ );
+
+ expect(result.current.showOverlay).toBe(true);
+ });
+
+ it("hides overlay in insert mode", () => {
+ textarea = createMockTextarea({ value: "hello" });
+ const ref = createRef();
+ (ref as { current: HTMLTextAreaElement }).current = textarea;
+
+ const { result } = renderHook(() =>
+ useViCursor({
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "insert",
+ enabled: true,
+ })
+ );
+
+ expect(result.current.showOverlay).toBe(false);
+ });
+
+ it("hides overlay when disabled", () => {
+ textarea = createMockTextarea({ value: "hello" });
+ const ref = createRef();
+ (ref as { current: HTMLTextAreaElement }).current = textarea;
+
+ const { result } = renderHook(() =>
+ useViCursor({
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "normal",
+ enabled: false,
+ })
+ );
+
+ expect(result.current.showOverlay).toBe(false);
+ });
+
+ it("toggles overlay when mode changes from normal to insert", () => {
+ textarea = createMockTextarea({ value: "hello" });
+ const ref = createRef();
+ (ref as { current: HTMLTextAreaElement }).current = textarea;
+
+ const initialProps: UseViCursorOptions = {
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "normal",
+ enabled: true,
+ };
+
+ const { result, rerender } = renderHook(
+ (props: UseViCursorOptions) => useViCursor(props),
+ { initialProps }
+ );
+
+ expect(result.current.showOverlay).toBe(true);
+
+ // Switch to insert mode
+ const insertProps: UseViCursorOptions = {
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "insert",
+ enabled: true,
+ };
+ rerender(insertProps);
+
+ expect(result.current.showOverlay).toBe(false);
+ });
+ });
+
+ describe("cursorStyle position", () => {
+ it("returns style object with top, left, height", () => {
+ textarea = createMockTextarea({ value: "hello" });
+ const ref = createRef();
+ (ref as { current: HTMLTextAreaElement }).current = textarea;
+
+ const { result } = renderHook(() =>
+ useViCursor({
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "normal",
+ enabled: true,
+ })
+ );
+
+ expect(result.current.cursorStyle).toHaveProperty("top");
+ expect(result.current.cursorStyle).toHaveProperty("left");
+ expect(result.current.cursorStyle).toHaveProperty("height");
+ expect(typeof result.current.cursorStyle.top).toBe("number");
+ expect(typeof result.current.cursorStyle.left).toBe("number");
+ expect(typeof result.current.cursorStyle.height).toBe("number");
+ });
+
+ it("recalculates position when cursorPosition changes", () => {
+ textarea = createMockTextarea({ value: "hello world" });
+ const ref = createRef();
+ (ref as { current: HTMLTextAreaElement }).current = textarea;
+
+ const { result, rerender } = renderHook(
+ (props) => useViCursor(props),
+ {
+ initialProps: {
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "normal" as const,
+ enabled: true,
+ },
+ }
+ );
+
+ // Verify initial state has position properties
+ expect(result.current.cursorStyle).toHaveProperty("top");
+ expect(result.current.cursorStyle).toHaveProperty("left");
+
+ // Move cursor to position 5 - should not throw
+ rerender({
+ textareaRef: ref,
+ cursorPosition: 5,
+ mode: "normal" as const,
+ enabled: true,
+ });
+
+ // Position properties should still exist after rerender
+ // Note: In JSDOM, getBoundingClientRect returns zeros, so we can't
+ // verify actual pixel positions. The real behavior is tested in
+ // browser integration tests.
+ expect(result.current.cursorStyle).toHaveProperty("top");
+ expect(result.current.cursorStyle).toHaveProperty("left");
+ });
+ });
+
+ describe("null ref handling", () => {
+ it("returns default position when textareaRef is null", () => {
+ const ref = createRef();
+ // ref.current is null by default
+
+ const { result } = renderHook(() =>
+ useViCursor({
+ textareaRef: ref,
+ cursorPosition: 0,
+ mode: "normal",
+ enabled: true,
+ })
+ );
+
+ // Should have default values without throwing
+ expect(result.current.cursorStyle).toHaveProperty("top");
+ expect(result.current.cursorStyle).toHaveProperty("left");
+ });
+ });
+});
+
+describe("calculateCursorPosition", () => {
+ let textarea: HTMLTextAreaElement | null = null;
+
+ afterEach(() => {
+ if (textarea && textarea.parentNode) {
+ document.body.removeChild(textarea);
+ }
+ textarea = null;
+ });
+
+ it("returns position with top, left, height", () => {
+ textarea = createMockTextarea({ value: "hello" });
+
+ const position = calculateCursorPosition(textarea, 0);
+
+ expect(position).toHaveProperty("top");
+ expect(position).toHaveProperty("left");
+ expect(position).toHaveProperty("height");
+ expect(typeof position.top).toBe("number");
+ expect(typeof position.left).toBe("number");
+ expect(typeof position.height).toBe("number");
+ });
+
+ it("calculates position for different cursor positions without error", () => {
+ textarea = createMockTextarea({ value: "hello world" });
+
+ // These should all complete without throwing
+ const pos0 = calculateCursorPosition(textarea, 0);
+ const pos5 = calculateCursorPosition(textarea, 5);
+ const pos10 = calculateCursorPosition(textarea, 10);
+
+ // All should return valid position objects
+ // Note: In JSDOM, getBoundingClientRect returns zeros, so we verify
+ // structure rather than actual pixel values
+ expect(pos0).toHaveProperty("top");
+ expect(pos0).toHaveProperty("left");
+ expect(pos5).toHaveProperty("top");
+ expect(pos5).toHaveProperty("left");
+ expect(pos10).toHaveProperty("top");
+ expect(pos10).toHaveProperty("left");
+ });
+
+ it("calculates position for different lines without error", () => {
+ textarea = createMockTextarea({ value: "line one\nline two\nline three" });
+
+ // These should all complete without throwing
+ const line1 = calculateCursorPosition(textarea, 0);
+ const line2 = calculateCursorPosition(textarea, 9); // Start of "line two"
+ const line3 = calculateCursorPosition(textarea, 18); // Start of "line three"
+
+ // All should return valid position objects
+ expect(line1).toHaveProperty("top");
+ expect(line1).toHaveProperty("height");
+ expect(line2).toHaveProperty("top");
+ expect(line2).toHaveProperty("height");
+ expect(line3).toHaveProperty("top");
+ expect(line3).toHaveProperty("height");
+ });
+
+ it("handles empty textarea", () => {
+ textarea = createMockTextarea({ value: "" });
+
+ // Should not throw
+ const position = calculateCursorPosition(textarea, 0);
+ expect(position).toHaveProperty("top");
+ expect(position).toHaveProperty("left");
+ expect(position).toHaveProperty("height");
+ });
+
+ it("handles cursor at end of text", () => {
+ textarea = createMockTextarea({ value: "hello" });
+
+ // Should not throw when cursor is at end
+ const position = calculateCursorPosition(textarea, 5);
+ expect(position).toHaveProperty("top");
+ });
+
+ it("cleans up mirror element after calculation", () => {
+ textarea = createMockTextarea({ value: "hello" });
+
+ const initialChildCount = document.body.children.length;
+ calculateCursorPosition(textarea, 0);
+ const finalChildCount = document.body.children.length;
+
+ // Should clean up mirror element (only textarea should remain as added element)
+ expect(finalChildCount).toBe(initialChildCount);
+ });
+
+ it("accounts for scroll offset in position", () => {
+ textarea = createMockTextarea({
+ value: "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np",
+ scrollTop: 0,
+ });
+
+ // Make textarea small so it can scroll
+ textarea.style.height = "50px";
+ textarea.style.overflow = "auto";
+
+ // Calculate position before scroll
+ calculateCursorPosition(textarea, 30); // Somewhere in the middle
+
+ // Simulate scroll
+ textarea.scrollTop = 20;
+
+ // Calculate position after scroll - key is that it doesn't throw
+ const posAfterScroll = calculateCursorPosition(textarea, 30);
+
+ // The top position should account for scroll
+ // In JSDOM, getBoundingClientRect returns zeros, so we just verify
+ // the function handles scroll without error
+ expect(posAfterScroll).toHaveProperty("top");
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useViMode.test.ts b/frontend/src/hooks/__tests__/useViMode.test.ts
new file mode 100644
index 00000000..ebe4848a
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useViMode.test.ts
@@ -0,0 +1,483 @@
+/**
+ * useViMode Hook Tests
+ *
+ * Tests the vi mode state machine: mode transitions between normal, insert, and command.
+ *
+ * @see .lore/plans/vi-mode-pair-writing.md
+ * @see REQ-4: Support three modes: Normal (default), Insert, and Command
+ * @see REQ-6: Esc returns to Normal mode from Insert or Command mode
+ */
+
+import { describe, it, expect } from "bun:test";
+import { renderHook, act } from "@testing-library/react";
+import { useViMode, type UseViModeOptions } from "../useViMode";
+
+// Helper to create a mock KeyboardEvent
+function createKeyEvent(
+ key: string,
+ options: Partial> = {}
+): React.KeyboardEvent {
+ const prevented = { value: false };
+ return {
+ key,
+ preventDefault: () => {
+ prevented.value = true;
+ },
+ get defaultPrevented() {
+ return prevented.value;
+ },
+ ctrlKey: false,
+ metaKey: false,
+ altKey: false,
+ shiftKey: false,
+ ...options,
+ } as React.KeyboardEvent;
+}
+
+describe("useViMode", () => {
+ const defaultOptions: UseViModeOptions = {
+ enabled: true,
+ };
+
+ describe("initial state", () => {
+ it("starts in normal mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ expect(result.current.mode).toBe("normal");
+ });
+
+ it("starts with empty command buffer", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ expect(result.current.commandBuffer).toBe("");
+ });
+
+ it("starts with null pending count", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ expect(result.current.pendingCount).toBeNull();
+ });
+
+ it("starts with null pending operator", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ expect(result.current.pendingOperator).toBeNull();
+ });
+
+ it("starts with null clipboard", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ expect(result.current.clipboard).toBeNull();
+ });
+ });
+
+ describe("normal to insert mode transitions", () => {
+ it("transitions to insert mode on 'i' key", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const event = createKeyEvent("i");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("transitions to insert mode on 'a' key", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const event = createKeyEvent("a");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(result.current.mode).toBe("insert");
+ });
+
+ it("transitions to insert mode on 'A' key", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const event = createKeyEvent("A");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(result.current.mode).toBe("insert");
+ });
+
+ it("transitions to insert mode on 'o' key", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const event = createKeyEvent("o");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(result.current.mode).toBe("insert");
+ });
+
+ it("transitions to insert mode on 'O' key", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const event = createKeyEvent("O");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(result.current.mode).toBe("insert");
+ });
+ });
+
+ describe("normal to command mode transitions", () => {
+ it("transitions to command mode on ':' key", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const event = createKeyEvent(":");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(result.current.mode).toBe("command");
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("clears command buffer when entering command mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // First enter command mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+
+ // Type something
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("w"));
+ });
+
+ expect(result.current.commandBuffer).toBe("w");
+
+ // Exit command mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Escape"));
+ });
+
+ // Re-enter command mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+
+ expect(result.current.commandBuffer).toBe("");
+ });
+ });
+
+ describe("insert to normal mode transitions", () => {
+ it("transitions to normal mode on Escape", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // Enter insert mode first
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+ expect(result.current.mode).toBe("insert");
+
+ // Press Escape
+ const escapeEvent = createKeyEvent("Escape");
+ act(() => {
+ result.current.handleKeyDown(escapeEvent);
+ });
+
+ expect(result.current.mode).toBe("normal");
+ expect(escapeEvent.defaultPrevented).toBe(true);
+ });
+
+ it("allows other keys through in insert mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // Enter insert mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ // Regular keys should not be prevented in insert mode
+ const letterEvent = createKeyEvent("x");
+ act(() => {
+ result.current.handleKeyDown(letterEvent);
+ });
+
+ expect(result.current.mode).toBe("insert"); // Still in insert mode
+ expect(letterEvent.defaultPrevented).toBe(false); // Key allowed through
+ });
+ });
+
+ describe("command to normal mode transitions", () => {
+ it("transitions to normal mode on Escape", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // Enter command mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+ expect(result.current.mode).toBe("command");
+
+ // Press Escape
+ const escapeEvent = createKeyEvent("Escape");
+ act(() => {
+ result.current.handleKeyDown(escapeEvent);
+ });
+
+ expect(result.current.mode).toBe("normal");
+ expect(escapeEvent.defaultPrevented).toBe(true);
+ });
+
+ it("clears command buffer on Escape", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // Enter command mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+
+ // Type something
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("w"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("q"));
+ });
+
+ expect(result.current.commandBuffer).toBe("wq");
+
+ // Press Escape
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Escape"));
+ });
+
+ expect(result.current.commandBuffer).toBe("");
+ });
+
+ it("transitions to normal mode on Enter", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // Enter command mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+
+ // Press Enter
+ const enterEvent = createKeyEvent("Enter");
+ act(() => {
+ result.current.handleKeyDown(enterEvent);
+ });
+
+ expect(result.current.mode).toBe("normal");
+ expect(enterEvent.defaultPrevented).toBe(true);
+ });
+
+ it("clears command buffer on Enter", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // Enter command mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+
+ // Type something
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("w"));
+ });
+
+ expect(result.current.commandBuffer).toBe("w");
+
+ // Press Enter
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Enter"));
+ });
+
+ expect(result.current.commandBuffer).toBe("");
+ });
+ });
+
+ describe("command buffer", () => {
+ it("accumulates characters in command mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("w"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("q"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("!"));
+ });
+
+ expect(result.current.commandBuffer).toBe("wq!");
+ });
+
+ it("handles backspace in command buffer", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("w"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("q"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Backspace"));
+ });
+
+ expect(result.current.commandBuffer).toBe("w");
+ });
+
+ it("prevents default for characters in command mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+
+ const wEvent = createKeyEvent("w");
+ act(() => {
+ result.current.handleKeyDown(wEvent);
+ });
+
+ expect(wEvent.defaultPrevented).toBe(true);
+ });
+ });
+
+ describe("normal mode key blocking", () => {
+ it("prevents default for letter keys in normal mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const xEvent = createKeyEvent("x");
+ act(() => {
+ result.current.handleKeyDown(xEvent);
+ });
+
+ expect(xEvent.defaultPrevented).toBe(true);
+ });
+
+ it("allows keys with ctrl modifier through in normal mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const ctrlCEvent = createKeyEvent("c", { ctrlKey: true });
+ act(() => {
+ result.current.handleKeyDown(ctrlCEvent);
+ });
+
+ expect(ctrlCEvent.defaultPrevented).toBe(false);
+ });
+
+ it("allows keys with meta modifier through in normal mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const metaCEvent = createKeyEvent("c", { metaKey: true });
+ act(() => {
+ result.current.handleKeyDown(metaCEvent);
+ });
+
+ expect(metaCEvent.defaultPrevented).toBe(false);
+ });
+
+ it("allows multi-character keys (like Arrow keys) through in normal mode", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ const arrowEvent = createKeyEvent("ArrowDown");
+ act(() => {
+ result.current.handleKeyDown(arrowEvent);
+ });
+
+ expect(arrowEvent.defaultPrevented).toBe(false);
+ });
+ });
+
+ describe("enabled option", () => {
+ it("does not change mode when disabled", () => {
+ const { result } = renderHook(() => useViMode({ enabled: false }));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ expect(result.current.mode).toBe("normal");
+ });
+
+ it("does not prevent default when disabled", () => {
+ const { result } = renderHook(() => useViMode({ enabled: false }));
+
+ const event = createKeyEvent("i");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it("handles dynamic enable/disable", () => {
+ const { result, rerender } = renderHook(
+ (props: UseViModeOptions) => useViMode(props),
+ { initialProps: { enabled: true } }
+ );
+
+ // Enter insert mode while enabled
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+ expect(result.current.mode).toBe("insert");
+
+ // Disable vi mode
+ rerender({ enabled: false });
+
+ // Escape should not work when disabled
+ const escapeEvent = createKeyEvent("Escape");
+ act(() => {
+ result.current.handleKeyDown(escapeEvent);
+ });
+
+ // Still in insert mode because vi mode is disabled
+ expect(result.current.mode).toBe("insert");
+ expect(escapeEvent.defaultPrevented).toBe(false);
+ });
+ });
+
+ describe("mode transition cycle", () => {
+ it("can cycle through all modes", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // Start in normal
+ expect(result.current.mode).toBe("normal");
+
+ // Normal -> Insert
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+ expect(result.current.mode).toBe("insert");
+
+ // Insert -> Normal
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Escape"));
+ });
+ expect(result.current.mode).toBe("normal");
+
+ // Normal -> Command
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+ expect(result.current.mode).toBe("command");
+
+ // Command -> Normal
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Escape"));
+ });
+ expect(result.current.mode).toBe("normal");
+ });
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useWebSocket.test.ts b/frontend/src/hooks/__tests__/useWebSocket.test.ts
index 357b6c6f..90f28385 100644
--- a/frontend/src/hooks/__tests__/useWebSocket.test.ts
+++ b/frontend/src/hooks/__tests__/useWebSocket.test.ts
@@ -265,6 +265,7 @@ describe("useWebSocket", () => {
badges: [],
order: 999999,
cardsEnabled: true,
+ viMode: false,
},
],
};
diff --git a/frontend/src/hooks/useHasKeyboard.test.ts b/frontend/src/hooks/useHasKeyboard.test.ts
new file mode 100644
index 00000000..74e97a85
--- /dev/null
+++ b/frontend/src/hooks/useHasKeyboard.test.ts
@@ -0,0 +1,97 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+import { detectKeyboard } from "./useHasKeyboard";
+
+describe("detectKeyboard", () => {
+ // Store original values to restore after each test
+ let originalMatchMedia: typeof window.matchMedia;
+ let originalMaxTouchPoints: number;
+
+ beforeEach(() => {
+ originalMatchMedia = window.matchMedia;
+ originalMaxTouchPoints = navigator.maxTouchPoints;
+ });
+
+ afterEach(() => {
+ window.matchMedia = originalMatchMedia;
+ Object.defineProperty(navigator, "maxTouchPoints", {
+ value: originalMaxTouchPoints,
+ configurable: true,
+ });
+ });
+
+ function mockMatchMedia(matches: boolean) {
+ window.matchMedia = ((query: string) => ({
+ matches: query === "(pointer: fine)" ? matches : false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => true,
+ })) as typeof window.matchMedia;
+ }
+
+ function mockMaxTouchPoints(value: number) {
+ Object.defineProperty(navigator, "maxTouchPoints", {
+ value,
+ configurable: true,
+ });
+ }
+
+ describe("desktop with mouse/trackpad", () => {
+ test("returns true when fine pointer detected", () => {
+ mockMatchMedia(true);
+ mockMaxTouchPoints(0);
+
+ expect(detectKeyboard()).toBe(true);
+ });
+
+ test("returns true when fine pointer detected even with touch support", () => {
+ // Laptop with touchscreen: has fine pointer AND touch
+ mockMatchMedia(true);
+ mockMaxTouchPoints(10);
+
+ expect(detectKeyboard()).toBe(true);
+ });
+ });
+
+ describe("keyboard-only device", () => {
+ test("returns true when no touch points available", () => {
+ mockMatchMedia(false);
+ mockMaxTouchPoints(0);
+
+ expect(detectKeyboard()).toBe(true);
+ });
+ });
+
+ describe("touch-only device", () => {
+ test("returns false when coarse pointer and touch points present", () => {
+ // Phone or tablet without keyboard
+ mockMatchMedia(false);
+ mockMaxTouchPoints(5);
+
+ expect(detectKeyboard()).toBe(false);
+ });
+
+ test("returns false with high touch point count", () => {
+ // Typical mobile device
+ mockMatchMedia(false);
+ mockMaxTouchPoints(10);
+
+ expect(detectKeyboard()).toBe(false);
+ });
+ });
+
+ describe("error handling", () => {
+ test("returns true when matchMedia throws", () => {
+ window.matchMedia = () => {
+ throw new Error("Not supported");
+ };
+ mockMaxTouchPoints(5);
+
+ // Should not throw, should return true as safe fallback
+ expect(detectKeyboard()).toBe(true);
+ });
+ });
+});
diff --git a/frontend/src/hooks/useHasKeyboard.ts b/frontend/src/hooks/useHasKeyboard.ts
new file mode 100644
index 00000000..1b1cde0e
--- /dev/null
+++ b/frontend/src/hooks/useHasKeyboard.ts
@@ -0,0 +1,53 @@
+/**
+ * Hook to detect physical keyboard presence.
+ *
+ * Uses two heuristics:
+ * 1. `matchMedia('(pointer: fine)')` - true for mouse/trackpad (implies desktop with keyboard)
+ * 2. `navigator.maxTouchPoints === 0` - no touch capability (implies keyboard-only device)
+ *
+ * If either condition is true, we assume a keyboard is present.
+ * If detection fails or is uncertain, returns true (safe fallback: assume keyboard exists).
+ *
+ * This is not reactive; keyboard presence doesn't change mid-session.
+ */
+
+/**
+ * Detect keyboard presence. Exported for testing.
+ */
+export function detectKeyboard(): boolean {
+ // Server-side or unusual environment: assume keyboard exists
+ if (typeof window === "undefined") {
+ return true;
+ }
+
+ try {
+ // Fine pointer (mouse/trackpad) implies desktop with keyboard
+ const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
+ if (hasFinePointer) {
+ return true;
+ }
+
+ // No touch capability implies keyboard-only device
+ const noTouchPoints = navigator.maxTouchPoints === 0;
+ if (noTouchPoints) {
+ return true;
+ }
+
+ // Touch-only device (no fine pointer, has touch points)
+ return false;
+ } catch {
+ // If detection fails, assume keyboard exists (safe fallback)
+ return true;
+ }
+}
+
+/**
+ * React hook that returns whether a physical keyboard is likely present.
+ *
+ * Used to enable/disable vi mode: vi mode requires a keyboard and is
+ * disabled on touch-only devices where it would be unusable.
+ */
+export function useHasKeyboard(): boolean {
+ // Detection happens once; result is stable for the session
+ return detectKeyboard();
+}
diff --git a/frontend/src/hooks/useViCursor.ts b/frontend/src/hooks/useViCursor.ts
new file mode 100644
index 00000000..b08c79d2
--- /dev/null
+++ b/frontend/src/hooks/useViCursor.ts
@@ -0,0 +1,269 @@
+/**
+ * useViCursor Hook - Vi Mode Block Cursor Position
+ *
+ * Calculates pixel position for the block cursor overlay in vi normal mode
+ * using the mirror element technique. A hidden div copies the textarea's
+ * styling and content to measure where the cursor should appear.
+ *
+ * @see .lore/plans/vi-mode-pair-writing.md (TD-10)
+ * @see .lore/research/vi-mode-implementation.md (Cursor Rendering Research)
+ */
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import type { ViMode } from "./useViMode";
+
+export interface UseViCursorOptions {
+ textareaRef: React.RefObject;
+ cursorPosition: number; // selectionStart
+ mode: ViMode;
+ enabled: boolean;
+}
+
+export interface CursorPosition {
+ top: number;
+ left: number;
+ height: number;
+}
+
+export interface UseViCursorResult {
+ cursorStyle: React.CSSProperties; // position for overlay
+ showOverlay: boolean; // true in normal/command mode
+}
+
+/**
+ * CSS properties that must be copied from textarea to mirror element
+ * for accurate position calculation.
+ */
+const MIRROR_STYLE_PROPERTIES = [
+ "fontFamily",
+ "fontSize",
+ "fontWeight",
+ "fontStyle",
+ "letterSpacing",
+ "lineHeight",
+ "textTransform",
+ "wordWrap",
+ "wordSpacing",
+ "whiteSpace",
+ "overflowWrap",
+ "tabSize",
+ "padding",
+ "paddingTop",
+ "paddingRight",
+ "paddingBottom",
+ "paddingLeft",
+ "borderWidth",
+ "borderTopWidth",
+ "borderRightWidth",
+ "borderBottomWidth",
+ "borderLeftWidth",
+ "boxSizing",
+ "width",
+] as const;
+
+/**
+ * Creates a mirror element for measuring cursor position.
+ * The mirror copies the textarea's styling so text wrapping matches.
+ */
+function createMirrorElement(): HTMLDivElement {
+ const mirror = document.createElement("div");
+ mirror.style.position = "absolute";
+ mirror.style.visibility = "hidden";
+ mirror.style.whiteSpace = "pre-wrap";
+ mirror.style.wordWrap = "break-word";
+ mirror.style.overflow = "hidden";
+ // Position off-screen
+ mirror.style.left = "-9999px";
+ mirror.style.top = "-9999px";
+ return mirror;
+}
+
+/**
+ * Converts camelCase to kebab-case for CSS property names.
+ */
+function toKebabCase(str: string): string {
+ return str.replace(/([A-Z])/g, "-$1").toLowerCase();
+}
+
+/**
+ * Copies relevant CSS properties from textarea to mirror element.
+ */
+function copyTextareaStyles(
+ textarea: HTMLTextAreaElement,
+ mirror: HTMLDivElement
+): void {
+ const computed = window.getComputedStyle(textarea);
+
+ for (const prop of MIRROR_STYLE_PROPERTIES) {
+ const kebabProp = toKebabCase(prop);
+ const value = computed.getPropertyValue(kebabProp);
+ mirror.style.setProperty(kebabProp, value);
+ }
+}
+
+/**
+ * Calculates cursor pixel position using the mirror element technique.
+ *
+ * Algorithm:
+ * 1. Create off-screen div with identical styling to textarea
+ * 2. Split content at cursor position
+ * 3. Insert span marker between text nodes
+ * 4. Measure span's getBoundingClientRect() for coordinates
+ * 5. Adjust for textarea position and scroll offset
+ */
+export function calculateCursorPosition(
+ textarea: HTMLTextAreaElement,
+ cursorPos: number
+): CursorPosition {
+ // Create and configure mirror
+ const mirror = createMirrorElement();
+ copyTextareaStyles(textarea, mirror);
+ document.body.appendChild(mirror);
+
+ try {
+ // Split text at cursor position
+ const value = textarea.value;
+ const textBefore = value.substring(0, cursorPos);
+ const charAtCursor = value.charAt(cursorPos);
+
+ // Build mirror content using safe DOM methods
+ // Use a span to mark cursor position
+ const cursorSpan = document.createElement("span");
+ // Use the actual character at cursor, or non-breaking space if empty/newline
+ cursorSpan.textContent =
+ charAtCursor && charAtCursor !== "\n" ? charAtCursor : "\u00A0";
+ cursorSpan.style.display = "inline";
+
+ mirror.textContent = "";
+
+ // Handle the text before cursor, preserving whitespace
+ if (textBefore) {
+ // Use createTextNode to preserve whitespace properly
+ const beforeNode = document.createTextNode(textBefore);
+ mirror.appendChild(beforeNode);
+ }
+ mirror.appendChild(cursorSpan);
+
+ // Get textarea's position
+ const textareaRect = textarea.getBoundingClientRect();
+ const spanRect = cursorSpan.getBoundingClientRect();
+
+ // Calculate position relative to textarea, accounting for scroll
+ const top = spanRect.top - textareaRect.top + textarea.scrollTop;
+ const left = spanRect.left - textareaRect.left + textarea.scrollLeft;
+
+ // Get line height from computed style for cursor height
+ const computed = window.getComputedStyle(textarea);
+ const lineHeight = parseFloat(computed.lineHeight) || 20;
+
+ return {
+ top,
+ left,
+ height: lineHeight,
+ };
+ } finally {
+ // Clean up mirror element
+ document.body.removeChild(mirror);
+ }
+}
+
+/**
+ * Hook for managing vi mode block cursor overlay position.
+ *
+ * Shows the overlay cursor in normal and command modes (when the native
+ * caret is hidden). Hides it in insert mode to let the native caret show.
+ *
+ * @example
+ * ```tsx
+ * const { cursorStyle, showOverlay } = useViCursor({
+ * textareaRef,
+ * cursorPosition: selectionStart,
+ * mode: viMode,
+ * enabled: viModeEnabled,
+ * });
+ *
+ * return (
+ * <>
+ *
+ * {showOverlay && }
+ * >
+ * );
+ * ```
+ */
+export function useViCursor(options: UseViCursorOptions): UseViCursorResult {
+ const { textareaRef, cursorPosition, mode, enabled } = options;
+
+ const [position, setPosition] = useState({
+ top: 0,
+ left: 0,
+ height: 20,
+ });
+
+ // Track scroll position to update cursor on scroll
+ const scrollTopRef = useRef(0);
+ const scrollLeftRef = useRef(0);
+
+ // Calculate cursor position
+ const updatePosition = useCallback(() => {
+ const textarea = textareaRef.current;
+ if (!textarea || !enabled) return;
+
+ const newPosition = calculateCursorPosition(textarea, cursorPosition);
+ setPosition(newPosition);
+
+ // Store current scroll for reference
+ scrollTopRef.current = textarea.scrollTop;
+ scrollLeftRef.current = textarea.scrollLeft;
+ }, [textareaRef, cursorPosition, enabled]);
+
+ // Update position when cursor moves
+ useEffect(() => {
+ updatePosition();
+ }, [updatePosition]);
+
+ // Update position on scroll
+ useEffect(() => {
+ const textarea = textareaRef.current;
+ if (!textarea || !enabled) return;
+
+ const handleScroll = () => {
+ // Recalculate position based on new scroll offset
+ const newPosition = calculateCursorPosition(textarea, cursorPosition);
+ setPosition(newPosition);
+ };
+
+ textarea.addEventListener("scroll", handleScroll);
+ return () => {
+ textarea.removeEventListener("scroll", handleScroll);
+ };
+ }, [textareaRef, cursorPosition, enabled]);
+
+ // Also update on window resize (textarea might reflow)
+ useEffect(() => {
+ if (!enabled) return;
+
+ const handleResize = () => {
+ updatePosition();
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ };
+ }, [enabled, updatePosition]);
+
+ // Show overlay in normal and command modes, not in insert mode
+ const showOverlay = enabled && (mode === "normal" || mode === "command");
+
+ // Convert position to CSS style
+ const cursorStyle: React.CSSProperties = {
+ top: position.top,
+ left: position.left,
+ height: position.height,
+ };
+
+ return {
+ cursorStyle,
+ showOverlay,
+ };
+}
diff --git a/frontend/src/hooks/useViMode.ts b/frontend/src/hooks/useViMode.ts
new file mode 100644
index 00000000..a6ff4219
--- /dev/null
+++ b/frontend/src/hooks/useViMode.ts
@@ -0,0 +1,177 @@
+/**
+ * useViMode Hook - Vi Mode State Machine
+ *
+ * Implements core vi mode state management for the Pair Writing Editor.
+ * Handles mode transitions between normal, insert, and command modes.
+ *
+ * This hook manages the state machine only. Actual cursor manipulation and
+ * text operations are added in subsequent chunks.
+ *
+ * @see .lore/plans/vi-mode-pair-writing.md (TD-1, TD-4)
+ */
+
+import { useState, useCallback, useRef } from "react";
+
+export type ViMode = "normal" | "insert" | "command";
+
+export interface UseViModeOptions {
+ enabled: boolean;
+ onSave?: () => void;
+ onExit?: () => void;
+ onQuitWithUnsaved?: () => void;
+ onContentChange?: (content: string) => void;
+}
+
+export interface UseViModeResult {
+ mode: ViMode;
+ handleKeyDown: (e: React.KeyboardEvent) => void;
+ commandBuffer: string;
+ pendingCount: number | null;
+ pendingOperator: "d" | "y" | null;
+ clipboard: string | null;
+}
+
+/**
+ * Keys that transition from normal mode to insert mode.
+ * For now, all of these just change mode. Cursor positioning (a, A, o, O)
+ * will be implemented in a later chunk.
+ */
+const INSERT_MODE_KEYS = new Set(["i", "a", "A", "o", "O"]);
+
+export function useViMode(options: UseViModeOptions): UseViModeResult {
+ const { enabled } = options;
+
+ const [mode, setMode] = useState("normal");
+ const [commandBuffer, setCommandBuffer] = useState("");
+
+ // These will be used in later chunks for commands/operations
+ const pendingCountRef = useRef(null);
+ const pendingOperatorRef = useRef<"d" | "y" | null>(null);
+ const clipboardRef = useRef(null);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ // When disabled, do nothing and let all keys through naturally
+ if (!enabled) {
+ return;
+ }
+
+ const { key } = e;
+
+ switch (mode) {
+ case "normal":
+ handleNormalModeKey(e, key, setMode, setCommandBuffer);
+ break;
+
+ case "insert":
+ handleInsertModeKey(e, key, setMode);
+ break;
+
+ case "command":
+ handleCommandModeKey(e, key, setMode, setCommandBuffer);
+ break;
+ }
+ },
+ [enabled, mode]
+ );
+
+ return {
+ mode,
+ handleKeyDown,
+ commandBuffer,
+ pendingCount: pendingCountRef.current,
+ pendingOperator: pendingOperatorRef.current,
+ clipboard: clipboardRef.current,
+ };
+}
+
+/**
+ * Handle keystrokes in normal mode.
+ * In normal mode, we intercept all keys and prevent default to stop character insertion.
+ */
+function handleNormalModeKey(
+ e: React.KeyboardEvent,
+ key: string,
+ setMode: React.Dispatch>,
+ setCommandBuffer: React.Dispatch>
+): void {
+ // Transition to insert mode
+ if (INSERT_MODE_KEYS.has(key)) {
+ e.preventDefault();
+ setMode("insert");
+ return;
+ }
+
+ // Transition to command mode
+ if (key === ":") {
+ e.preventDefault();
+ setMode("command");
+ setCommandBuffer("");
+ return;
+ }
+
+ // In normal mode, prevent default for all single-character keys to stop insertion
+ // Allow modifier keys and navigation keys to work naturally
+ if (key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
+ e.preventDefault();
+ }
+}
+
+/**
+ * Handle keystrokes in insert mode.
+ * In insert mode, we only intercept Escape to return to normal mode.
+ * All other keys pass through naturally for text editing.
+ */
+function handleInsertModeKey(
+ e: React.KeyboardEvent,
+ key: string,
+ setMode: React.Dispatch>
+): void {
+ if (key === "Escape") {
+ e.preventDefault();
+ setMode("normal");
+ }
+ // All other keys pass through naturally
+}
+
+/**
+ * Handle keystrokes in command mode.
+ * Escape returns to normal mode.
+ * Enter will execute the command (implemented in later chunk).
+ * Other keys are captured in the command buffer (implemented in later chunk).
+ */
+function handleCommandModeKey(
+ e: React.KeyboardEvent,
+ key: string,
+ setMode: React.Dispatch>,
+ setCommandBuffer: React.Dispatch>
+): void {
+ if (key === "Escape") {
+ e.preventDefault();
+ setMode("normal");
+ setCommandBuffer("");
+ return;
+ }
+
+ if (key === "Enter") {
+ e.preventDefault();
+ // Command execution will be added in a later chunk
+ // For now, just return to normal mode
+ setMode("normal");
+ setCommandBuffer("");
+ return;
+ }
+
+ // Prevent default for all keys in command mode to avoid inserting into textarea
+ // Command buffer building will be implemented in a later chunk
+ if (key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
+ e.preventDefault();
+ setCommandBuffer((prev) => prev + key);
+ }
+
+ // Handle backspace in command buffer
+ if (key === "Backspace") {
+ e.preventDefault();
+ setCommandBuffer((prev) => prev.slice(0, -1));
+ }
+}
diff --git a/frontend/src/test-helpers.ts b/frontend/src/test-helpers.ts
index 1914a7d0..7044c4f7 100644
--- a/frontend/src/test-helpers.ts
+++ b/frontend/src/test-helpers.ts
@@ -33,5 +33,7 @@ export function createMockVault(overrides: Partial = {}): VaultInfo {
order: overrides.order ?? 999999,
// Ensure cardsEnabled is always a boolean
cardsEnabled: overrides.cardsEnabled ?? true,
+ // Ensure viMode is always a boolean
+ viMode: overrides.viMode ?? false,
};
}
diff --git a/shared/src/__tests__/protocol.test.ts b/shared/src/__tests__/protocol.test.ts
index a7475728..4ea699c4 100644
--- a/shared/src/__tests__/protocol.test.ts
+++ b/shared/src/__tests__/protocol.test.ts
@@ -91,6 +91,7 @@ describe("VaultInfoSchema", () => {
badges: [],
order: 0,
cardsEnabled: true,
+ viMode: false,
};
const result = VaultInfoSchema.parse(validVault);
@@ -573,6 +574,7 @@ describe("Server -> Client Messages", () => {
badges: [],
order: 1,
cardsEnabled: true,
+ viMode: false,
},
],
};
diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts
index 3030b905..86f24ecb 100644
--- a/shared/src/protocol.ts
+++ b/shared/src/protocol.ts
@@ -68,6 +68,7 @@ export const EditableVaultConfigSchema = z.object({
badges: z.array(EditableBadgeSchema).max(5).optional(),
order: z.number().int().min(1).optional(),
cardsEnabled: z.boolean().optional(),
+ viMode: z.boolean().optional(),
});
// =============================================================================
@@ -98,6 +99,7 @@ export const VaultInfoSchema = z.object({
badges: z.array(BadgeSchema),
order: z.number(), // Can be Infinity for unset vaults
cardsEnabled: z.boolean(),
+ viMode: z.boolean(),
});
// =============================================================================
diff --git a/shared/src/types.ts b/shared/src/types.ts
index 6a742ed5..63672ab6 100644
--- a/shared/src/types.ts
+++ b/shared/src/types.ts
@@ -50,6 +50,7 @@ export interface Badge {
* @property badges - Custom badges configured in .memory-loop.json
* @property order - Display order for vault selection (lower values first, Infinity for unset)
* @property cardsEnabled - Whether spaced repetition card discovery is enabled (default: true)
+ * @property viMode - Whether vi mode is enabled for Pair Writing editor (default: false)
*/
export interface VaultInfo {
id: string;
@@ -72,6 +73,7 @@ export interface VaultInfo {
badges: Badge[];
order: number;
cardsEnabled: boolean;
+ viMode: boolean;
}
/**
From e3780ae0268d0f2e78a9608d007cfb8258fa3bf2 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 09:30:56 -0800
Subject: [PATCH 02/18] fix: Address code review findings for vi mode chunks
1-5
- Export ViCursor from pair-writing/index.ts for public API
- Remove duplicate ViMode type from ViModeIndicator, import from useViMode
- Add Ctrl+C handling in command mode to abort (standard vi behavior)
- Add test for Ctrl+C command mode abort
Co-Authored-By: Claude Opus 4.5
---
.../pair-writing/ViModeIndicator.tsx | 3 +--
.../__tests__/ViModeIndicator.test.tsx | 3 ++-
frontend/src/components/pair-writing/index.ts | 4 ++-
.../src/hooks/__tests__/useViMode.test.ts | 26 +++++++++++++++++++
frontend/src/hooks/useViMode.ts | 3 ++-
5 files changed, 34 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/pair-writing/ViModeIndicator.tsx b/frontend/src/components/pair-writing/ViModeIndicator.tsx
index 3c391c88..e58efb43 100644
--- a/frontend/src/components/pair-writing/ViModeIndicator.tsx
+++ b/frontend/src/components/pair-writing/ViModeIndicator.tsx
@@ -7,10 +7,9 @@
* @see .lore/specs/vi-mode-pair-writing.md REQ-5
*/
+import type { ViMode } from "../../hooks/useViMode";
import "./vi-mode.css";
-export type ViMode = "normal" | "insert" | "command";
-
export interface ViModeIndicatorProps {
/** Current vi mode */
mode: ViMode;
diff --git a/frontend/src/components/pair-writing/__tests__/ViModeIndicator.test.tsx b/frontend/src/components/pair-writing/__tests__/ViModeIndicator.test.tsx
index 1b06b811..fd0fafda 100644
--- a/frontend/src/components/pair-writing/__tests__/ViModeIndicator.test.tsx
+++ b/frontend/src/components/pair-writing/__tests__/ViModeIndicator.test.tsx
@@ -12,7 +12,8 @@
import { describe, it, expect, afterEach } from "bun:test";
import { render, screen, cleanup } from "@testing-library/react";
-import { ViModeIndicator, type ViMode } from "../ViModeIndicator";
+import { ViModeIndicator } from "../ViModeIndicator";
+import type { ViMode } from "../../../hooks/useViMode";
afterEach(() => {
cleanup();
diff --git a/frontend/src/components/pair-writing/index.ts b/frontend/src/components/pair-writing/index.ts
index 205bb3f0..35690d38 100644
--- a/frontend/src/components/pair-writing/index.ts
+++ b/frontend/src/components/pair-writing/index.ts
@@ -5,4 +5,6 @@
export { PairWritingMode, type PairWritingModeProps } from "./PairWritingMode";
export { PairWritingEditor, type PairWritingEditorProps } from "./PairWritingEditor";
export { PairWritingToolbar, type PairWritingToolbarProps } from "./PairWritingToolbar";
-export { ViModeIndicator, type ViModeIndicatorProps, type ViMode } from "./ViModeIndicator";
+export { ViModeIndicator, type ViModeIndicatorProps } from "./ViModeIndicator";
+export { ViCursor, type ViCursorProps } from "./ViCursor";
+export type { ViMode } from "../../hooks/useViMode";
diff --git a/frontend/src/hooks/__tests__/useViMode.test.ts b/frontend/src/hooks/__tests__/useViMode.test.ts
index ebe4848a..a9a26c62 100644
--- a/frontend/src/hooks/__tests__/useViMode.test.ts
+++ b/frontend/src/hooks/__tests__/useViMode.test.ts
@@ -256,6 +256,32 @@ describe("useViMode", () => {
expect(result.current.commandBuffer).toBe("");
});
+ it("transitions to normal mode on Ctrl+C", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ // Enter command mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+ expect(result.current.mode).toBe("command");
+
+ // Type something
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("w"));
+ });
+ expect(result.current.commandBuffer).toBe("w");
+
+ // Press Ctrl+C (standard vi abort)
+ const ctrlCEvent = createKeyEvent("c", { ctrlKey: true });
+ act(() => {
+ result.current.handleKeyDown(ctrlCEvent);
+ });
+
+ expect(result.current.mode).toBe("normal");
+ expect(result.current.commandBuffer).toBe("");
+ expect(ctrlCEvent.defaultPrevented).toBe(true);
+ });
+
it("transitions to normal mode on Enter", () => {
const { result } = renderHook(() => useViMode(defaultOptions));
diff --git a/frontend/src/hooks/useViMode.ts b/frontend/src/hooks/useViMode.ts
index a6ff4219..e1fb4c39 100644
--- a/frontend/src/hooks/useViMode.ts
+++ b/frontend/src/hooks/useViMode.ts
@@ -146,7 +146,8 @@ function handleCommandModeKey(
setMode: React.Dispatch>,
setCommandBuffer: React.Dispatch>
): void {
- if (key === "Escape") {
+ // Escape or Ctrl+C aborts command mode (standard vi behavior)
+ if (key === "Escape" || (e.ctrlKey && key === "c")) {
e.preventDefault();
setMode("normal");
setCommandBuffer("");
From 64edb0ba23ae6e81e67d4b8bb59ccb20e1c6d908 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 09:31:41 -0800
Subject: [PATCH 03/18] docs: Mark vi mode chunks 1-5 as complete in work doc
Co-Authored-By: Claude Opus 4.5
---
.lore/work/vi-mode-pair-writing.md | 28 +++++++++++++++++-----------
1 file changed, 17 insertions(+), 11 deletions(-)
diff --git a/.lore/work/vi-mode-pair-writing.md b/.lore/work/vi-mode-pair-writing.md
index 653f66bf..fae9d93e 100644
--- a/.lore/work/vi-mode-pair-writing.md
+++ b/.lore/work/vi-mode-pair-writing.md
@@ -5,10 +5,11 @@ Plan: `.lore/plans/vi-mode-pair-writing.md`
## Chunks
-### 1. Configuration Layer
+### 1. Configuration Layer ✓
**What**: Add `viMode` to VaultConfig and flow it through to frontend
**Delivers**: Config toggle that can be read in PairWritingEditor (no behavior change yet)
**Depends on**: Nothing
+**Completed**: 2026-01-29
Tasks:
- Add `viMode?: boolean` to `VaultConfig` interface (backend)
@@ -17,20 +18,22 @@ Tasks:
- Flow through vault discovery to API response
- Verify config is accessible in `PairWritingMode` component
-### 2. Keyboard Detection
+### 2. Keyboard Detection ✓
**What**: Detect physical keyboard presence, gate vi mode on it
**Delivers**: `useHasKeyboard` hook that returns boolean; vi mode disabled on touch-only
**Depends on**: Nothing (can parallel with Chunk 1)
+**Completed**: 2026-01-29
Tasks:
- Create `useHasKeyboard.ts` hook using matchMedia + maxTouchPoints
- Add tests for detection logic (mock matchMedia)
- Export from hooks index
-### 3. Vi Mode State Machine (Core)
+### 3. Vi Mode State Machine (Core) ✓
**What**: Mode state management (normal/insert/command) and transitions
**Delivers**: `useViMode` hook with mode state, no commands yet
**Depends on**: Nothing (can parallel)
+**Completed**: 2026-01-29
Tasks:
- Create `useViMode.ts` with mode enum and state
@@ -39,10 +42,11 @@ Tasks:
- Add `enabled` option to bypass when vi mode off
- Unit tests for state transitions
-### 4. Cursor Overlay System
+### 4. Cursor Overlay System ✓
**What**: Block cursor rendering using mirror element technique
**Delivers**: `useViCursor` hook + `ViCursor` component showing cursor position
**Depends on**: Chunk 3 (needs mode to toggle cursor style)
+**Completed**: 2026-01-29
Tasks:
- Create `useViCursor.ts` with mirror element position calculation
@@ -52,10 +56,11 @@ Tasks:
- Handle scroll synchronization
- Unit tests for position calculation
-### 5. Mode Indicator UI
+### 5. Mode Indicator UI ✓
**What**: Display current mode ("-- NORMAL --", "-- INSERT --", etc.)
**Delivers**: `ViModeIndicator` component visible in editor
**Depends on**: Chunk 3 (needs mode state)
+**Completed**: 2026-01-29
Tasks:
- Create `ViModeIndicator.tsx` component
@@ -195,11 +200,11 @@ Tasks:
## Suggested Order
-1. **Chunk 1** (Configuration) - Foundation, quick win
-2. **Chunk 2** (Keyboard Detection) - Can parallel with 1
-3. **Chunk 3** (State Machine) - Core architecture, can parallel
-4. **Chunk 4** (Cursor Overlay) - Visual feedback, depends on 3
-5. **Chunk 5** (Mode Indicator) - Quick UI win, depends on 3
+1. ~~**Chunk 1** (Configuration) - Foundation, quick win~~ ✓
+2. ~~**Chunk 2** (Keyboard Detection) - Can parallel with 1~~ ✓
+3. ~~**Chunk 3** (State Machine) - Core architecture, can parallel~~ ✓
+4. ~~**Chunk 4** (Cursor Overlay) - Visual feedback, depends on 3~~ ✓
+5. ~~**Chunk 5** (Mode Indicator) - Quick UI win, depends on 3~~ ✓
6. **Chunk 6** (Basic Movement) - First real vi behavior
7. **Chunk 7** (Insert Entry) - Can now edit text
8. **Chunk 8** (Undo Stack) - Safety net before destructive commands
@@ -213,7 +218,8 @@ Tasks:
## Release Points
-**Milestone A (Chunks 1-5)**: Vi mode toggle works, shows mode indicator and block cursor. No commands yet, but visual infrastructure complete.
+**Milestone A (Chunks 1-5)**: ✓ COMPLETE (2026-01-29)
+Vi mode toggle works, shows mode indicator and block cursor. No commands yet, but visual infrastructure complete.
**Milestone B (Chunks 6-7)**: Navigation and insert mode work. Can move around and type text.
From 6f2dbdee35e2f2d27b5f784ee7fe8722f0b7284b Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 10:20:39 -0800
Subject: [PATCH 04/18] feat(vi-mode): Implement basic movement and insert
entry commands
Chunk 6 - Basic Movement Commands:
- Add h/j/k/l for character and line movement
- Add 0/$ for line start/end movement
- Export helper functions: getLineInfo, getLineCount, getLinePositions, moveCursor
- Handle boundary clamping and column preservation
Chunk 7 - Insert Mode Entry Commands:
- i: insert at cursor (existing, confirmed working)
- a: append after cursor
- A: append at end of line
- o: open line below
- O: open line above
- Wire up onContentChange callback for o/O operations
Completes Milestone B - navigation and insert mode now functional.
Co-Authored-By: Claude Opus 4.5
---
.lore/work/vi-mode-pair-writing.md | 13 +-
.../src/hooks/__tests__/useViMode.test.ts | 1380 ++++++++++++++++-
frontend/src/hooks/useViMode.ts | 315 +++-
3 files changed, 1690 insertions(+), 18 deletions(-)
diff --git a/.lore/work/vi-mode-pair-writing.md b/.lore/work/vi-mode-pair-writing.md
index fae9d93e..546c6b92 100644
--- a/.lore/work/vi-mode-pair-writing.md
+++ b/.lore/work/vi-mode-pair-writing.md
@@ -68,10 +68,11 @@ Tasks:
- Show/hide based on vi mode enabled
- Add to `vi-mode.css`
-### 6. Basic Movement Commands
+### 6. Basic Movement Commands ✓
**What**: `h`, `j`, `k`, `l`, `0`, `$` cursor movement
**Delivers**: Navigation works in Normal mode
**Depends on**: Chunks 3, 4 (need mode + cursor)
+**Completed**: 2026-01-29
Tasks:
- Add command dispatch to `useViMode`
@@ -81,10 +82,11 @@ Tasks:
- Handle document boundaries (clamp, don't wrap)
- Unit tests for each movement command
-### 7. Insert Mode Entry Commands
+### 7. Insert Mode Entry Commands ✓
**What**: `i`, `a`, `A`, `o`, `O` to enter Insert mode
**Delivers**: Can enter Insert mode at various positions
**Depends on**: Chunk 6 (builds on cursor manipulation)
+**Completed**: 2026-01-29
Tasks:
- `i`: enter Insert at cursor
@@ -205,8 +207,8 @@ Tasks:
3. ~~**Chunk 3** (State Machine) - Core architecture, can parallel~~ ✓
4. ~~**Chunk 4** (Cursor Overlay) - Visual feedback, depends on 3~~ ✓
5. ~~**Chunk 5** (Mode Indicator) - Quick UI win, depends on 3~~ ✓
-6. **Chunk 6** (Basic Movement) - First real vi behavior
-7. **Chunk 7** (Insert Entry) - Can now edit text
+6. ~~**Chunk 6** (Basic Movement) - First real vi behavior~~ ✓
+7. ~~**Chunk 7** (Insert Entry) - Can now edit text~~ ✓
8. **Chunk 8** (Undo Stack) - Safety net before destructive commands
9. **Chunk 9** (Delete) - First destructive command
10. **Chunk 10** (Yank/Put) - Copy/paste
@@ -221,7 +223,8 @@ Tasks:
**Milestone A (Chunks 1-5)**: ✓ COMPLETE (2026-01-29)
Vi mode toggle works, shows mode indicator and block cursor. No commands yet, but visual infrastructure complete.
-**Milestone B (Chunks 6-7)**: Navigation and insert mode work. Can move around and type text.
+**Milestone B (Chunks 6-7)**: ✓ COMPLETE (2026-01-29)
+Navigation and insert mode work. Can move around and type text.
**Milestone C (Chunks 8-11)**: Full Normal mode editing. Delete, yank, put, undo, counts all work.
diff --git a/frontend/src/hooks/__tests__/useViMode.test.ts b/frontend/src/hooks/__tests__/useViMode.test.ts
index a9a26c62..55867499 100644
--- a/frontend/src/hooks/__tests__/useViMode.test.ts
+++ b/frontend/src/hooks/__tests__/useViMode.test.ts
@@ -2,15 +2,25 @@
* useViMode Hook Tests
*
* Tests the vi mode state machine: mode transitions between normal, insert, and command.
+ * Also tests movement commands (h, j, k, l, 0, $) in normal mode.
*
* @see .lore/plans/vi-mode-pair-writing.md
* @see REQ-4: Support three modes: Normal (default), Insert, and Command
* @see REQ-6: Esc returns to Normal mode from Insert or Command mode
+ * @see REQ-7: Movement: h (left), j (down), k (up), l (right)
+ * @see REQ-8: Line movement: 0 (start of line), $ (end of line)
*/
-import { describe, it, expect } from "bun:test";
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { renderHook, act } from "@testing-library/react";
-import { useViMode, type UseViModeOptions } from "../useViMode";
+import {
+ useViMode,
+ type UseViModeOptions,
+ getLineInfo,
+ getLineCount,
+ getLinePositions,
+ moveCursor,
+} from "../useViMode";
// Helper to create a mock KeyboardEvent
function createKeyEvent(
@@ -507,3 +517,1369 @@ describe("useViMode", () => {
});
});
});
+
+/**
+ * Helper function tests for cursor manipulation utilities.
+ */
+describe("cursor manipulation helpers", () => {
+ describe("getLineInfo", () => {
+ it("returns correct info for single line text", () => {
+ const text = "Hello, world!";
+ const info = getLineInfo(text, 7);
+
+ expect(info.lineNumber).toBe(0);
+ expect(info.lineStart).toBe(0);
+ expect(info.lineEnd).toBe(13);
+ expect(info.column).toBe(7);
+ });
+
+ it("returns correct info for cursor at start of text", () => {
+ const text = "Hello, world!";
+ const info = getLineInfo(text, 0);
+
+ expect(info.lineNumber).toBe(0);
+ expect(info.lineStart).toBe(0);
+ expect(info.lineEnd).toBe(13);
+ expect(info.column).toBe(0);
+ });
+
+ it("returns correct info for cursor at end of text", () => {
+ const text = "Hello, world!";
+ const info = getLineInfo(text, 13);
+
+ expect(info.lineNumber).toBe(0);
+ expect(info.lineStart).toBe(0);
+ expect(info.lineEnd).toBe(13);
+ expect(info.column).toBe(13);
+ });
+
+ it("returns correct info for multiline text - first line", () => {
+ const text = "Line 1\nLine 2\nLine 3";
+ const info = getLineInfo(text, 3); // In "Line 1"
+
+ expect(info.lineNumber).toBe(0);
+ expect(info.lineStart).toBe(0);
+ expect(info.lineEnd).toBe(6);
+ expect(info.column).toBe(3);
+ });
+
+ it("returns correct info for multiline text - second line", () => {
+ const text = "Line 1\nLine 2\nLine 3";
+ const info = getLineInfo(text, 10); // In "Line 2"
+
+ expect(info.lineNumber).toBe(1);
+ expect(info.lineStart).toBe(7);
+ expect(info.lineEnd).toBe(13);
+ expect(info.column).toBe(3);
+ });
+
+ it("returns correct info for multiline text - last line", () => {
+ const text = "Line 1\nLine 2\nLine 3";
+ const info = getLineInfo(text, 17); // In "Line 3"
+
+ expect(info.lineNumber).toBe(2);
+ expect(info.lineStart).toBe(14);
+ expect(info.lineEnd).toBe(20);
+ expect(info.column).toBe(3);
+ });
+
+ it("handles empty text", () => {
+ const text = "";
+ const info = getLineInfo(text, 0);
+
+ expect(info.lineNumber).toBe(0);
+ expect(info.lineStart).toBe(0);
+ expect(info.lineEnd).toBe(0);
+ expect(info.column).toBe(0);
+ });
+
+ it("handles cursor right after newline", () => {
+ const text = "Line 1\nLine 2";
+ const info = getLineInfo(text, 7); // Right after newline
+
+ expect(info.lineNumber).toBe(1);
+ expect(info.lineStart).toBe(7);
+ expect(info.column).toBe(0);
+ });
+
+ it("handles empty line in middle", () => {
+ const text = "Line 1\n\nLine 3";
+ const info = getLineInfo(text, 7); // On empty line
+
+ expect(info.lineNumber).toBe(1);
+ expect(info.lineStart).toBe(7);
+ expect(info.lineEnd).toBe(7);
+ expect(info.column).toBe(0);
+ });
+ });
+
+ describe("getLineCount", () => {
+ it("returns 1 for empty string", () => {
+ expect(getLineCount("")).toBe(1);
+ });
+
+ it("returns 1 for single line without newline", () => {
+ expect(getLineCount("Hello")).toBe(1);
+ });
+
+ it("returns 2 for text with one newline", () => {
+ expect(getLineCount("Hello\nWorld")).toBe(2);
+ });
+
+ it("returns 3 for text with two newlines", () => {
+ expect(getLineCount("Line 1\nLine 2\nLine 3")).toBe(3);
+ });
+
+ it("counts trailing newline as additional line", () => {
+ expect(getLineCount("Hello\n")).toBe(2);
+ });
+ });
+
+ describe("getLinePositions", () => {
+ it("returns correct positions for first line", () => {
+ const text = "Line 1\nLine 2\nLine 3";
+ const result = getLinePositions(text, 0);
+
+ expect(result).not.toBeNull();
+ expect(result!.lineStart).toBe(0);
+ expect(result!.lineEnd).toBe(6);
+ });
+
+ it("returns correct positions for middle line", () => {
+ const text = "Line 1\nLine 2\nLine 3";
+ const result = getLinePositions(text, 1);
+
+ expect(result).not.toBeNull();
+ expect(result!.lineStart).toBe(7);
+ expect(result!.lineEnd).toBe(13);
+ });
+
+ it("returns correct positions for last line", () => {
+ const text = "Line 1\nLine 2\nLine 3";
+ const result = getLinePositions(text, 2);
+
+ expect(result).not.toBeNull();
+ expect(result!.lineStart).toBe(14);
+ expect(result!.lineEnd).toBe(20);
+ });
+
+ it("returns null for non-existent line", () => {
+ const text = "Line 1\nLine 2";
+ const result = getLinePositions(text, 5);
+
+ expect(result).toBeNull();
+ });
+
+ it("handles single line text", () => {
+ const text = "Hello";
+ const result = getLinePositions(text, 0);
+
+ expect(result).not.toBeNull();
+ expect(result!.lineStart).toBe(0);
+ expect(result!.lineEnd).toBe(5);
+ });
+
+ it("handles empty line", () => {
+ const text = "Line 1\n\nLine 3";
+ const result = getLinePositions(text, 1);
+
+ expect(result).not.toBeNull();
+ expect(result!.lineStart).toBe(7);
+ expect(result!.lineEnd).toBe(7);
+ });
+ });
+
+ describe("moveCursor", () => {
+ let textarea: HTMLTextAreaElement;
+
+ beforeEach(() => {
+ textarea = document.createElement("textarea");
+ textarea.value = "Hello, world!";
+ document.body.appendChild(textarea);
+ });
+
+ afterEach(() => {
+ textarea.remove();
+ });
+
+ it("sets selectionStart and selectionEnd to same value", () => {
+ moveCursor(textarea, 5);
+
+ expect(textarea.selectionStart).toBe(5);
+ expect(textarea.selectionEnd).toBe(5);
+ });
+
+ it("clamps to 0 when position is negative", () => {
+ moveCursor(textarea, -5);
+
+ expect(textarea.selectionStart).toBe(0);
+ expect(textarea.selectionEnd).toBe(0);
+ });
+
+ it("clamps to text length when position exceeds length", () => {
+ moveCursor(textarea, 100);
+
+ expect(textarea.selectionStart).toBe(13);
+ expect(textarea.selectionEnd).toBe(13);
+ });
+
+ it("handles position at start", () => {
+ moveCursor(textarea, 0);
+
+ expect(textarea.selectionStart).toBe(0);
+ expect(textarea.selectionEnd).toBe(0);
+ });
+
+ it("handles position at end", () => {
+ moveCursor(textarea, 13);
+
+ expect(textarea.selectionStart).toBe(13);
+ expect(textarea.selectionEnd).toBe(13);
+ });
+ });
+});
+
+/**
+ * Movement command tests.
+ *
+ * @see REQ-7: Movement: h (left), j (down), k (up), l (right)
+ * @see REQ-8: Line movement: 0 (start of line), $ (end of line)
+ */
+describe("movement commands", () => {
+ let textarea: HTMLTextAreaElement;
+ let textareaRef: React.RefObject;
+
+ function createTextareaRef(ta: HTMLTextAreaElement) {
+ return { current: ta } as React.RefObject;
+ }
+
+ beforeEach(() => {
+ textarea = document.createElement("textarea");
+ document.body.appendChild(textarea);
+ textareaRef = createTextareaRef(textarea);
+ });
+
+ afterEach(() => {
+ textarea.remove();
+ });
+
+ function getOptionsWithTextarea(): UseViModeOptions {
+ return {
+ enabled: true,
+ textareaRef,
+ };
+ }
+
+ describe("h (move left)", () => {
+ it("moves cursor left one character", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(textarea.selectionStart).toBe(2);
+ expect(textarea.selectionEnd).toBe(2);
+ });
+
+ it("stays at position 0 when already at start", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(textarea.selectionStart).toBe(0);
+ expect(textarea.selectionEnd).toBe(0);
+ });
+
+ it("moves from position 1 to position 0", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 1;
+ textarea.selectionEnd = 1;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(textarea.selectionStart).toBe(0);
+ expect(textarea.selectionEnd).toBe(0);
+ });
+
+ it("collapses selection when moving", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 1;
+ textarea.selectionEnd = 4; // Selection exists
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ // Moves based on selectionStart, collapses selection
+ expect(textarea.selectionStart).toBe(0);
+ expect(textarea.selectionEnd).toBe(0);
+ });
+ });
+
+ describe("l (move right)", () => {
+ it("moves cursor right one character", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("l"));
+ });
+
+ expect(textarea.selectionStart).toBe(3);
+ expect(textarea.selectionEnd).toBe(3);
+ });
+
+ it("stays at end when already at end", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 5;
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("l"));
+ });
+
+ expect(textarea.selectionStart).toBe(5);
+ expect(textarea.selectionEnd).toBe(5);
+ });
+
+ it("moves from second-to-last to last position", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 4;
+ textarea.selectionEnd = 4;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("l"));
+ });
+
+ expect(textarea.selectionStart).toBe(5);
+ expect(textarea.selectionEnd).toBe(5);
+ });
+ });
+
+ describe("j (move down)", () => {
+ it("moves cursor down one line", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 3; // "Lin|e 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("j"));
+ });
+
+ // Should be at position 10 ("Lin|e 2")
+ expect(textarea.selectionStart).toBe(10);
+ expect(textarea.selectionEnd).toBe(10);
+ });
+
+ it("maintains column position when moving down", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 2; // "He|llo"
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("j"));
+ });
+
+ // Should be at "Wo|rld" (position 8)
+ expect(textarea.selectionStart).toBe(8);
+ expect(textarea.selectionEnd).toBe(8);
+ });
+
+ it("clamps column when next line is shorter", () => {
+ textarea.value = "Hello World\nHi";
+ textarea.selectionStart = 8; // "Hello Wo|rld"
+ textarea.selectionEnd = 8;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("j"));
+ });
+
+ // Next line only has 2 chars, should clamp to end (position 14 = 12 + 2)
+ expect(textarea.selectionStart).toBe(14);
+ expect(textarea.selectionEnd).toBe(14);
+ });
+
+ it("stays on last line when already at last line", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 17; // "Lin|e 3"
+ textarea.selectionEnd = 17;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("j"));
+ });
+
+ // Should stay at same position
+ expect(textarea.selectionStart).toBe(17);
+ expect(textarea.selectionEnd).toBe(17);
+ });
+
+ it("works with single line text (stays in place)", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("j"));
+ });
+
+ expect(textarea.selectionStart).toBe(3);
+ expect(textarea.selectionEnd).toBe(3);
+ });
+
+ it("handles empty lines", () => {
+ textarea.value = "Line 1\n\nLine 3";
+ textarea.selectionStart = 3; // "Lin|e 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("j"));
+ });
+
+ // Should go to empty line, column clamped to 0 (position 7)
+ expect(textarea.selectionStart).toBe(7);
+ expect(textarea.selectionEnd).toBe(7);
+ });
+ });
+
+ describe("k (move up)", () => {
+ it("moves cursor up one line", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 10; // "Lin|e 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("k"));
+ });
+
+ // Should be at position 3 ("Lin|e 1")
+ expect(textarea.selectionStart).toBe(3);
+ expect(textarea.selectionEnd).toBe(3);
+ });
+
+ it("maintains column position when moving up", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 8; // "Wo|rld"
+ textarea.selectionEnd = 8;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("k"));
+ });
+
+ // Should be at "He|llo" (position 2)
+ expect(textarea.selectionStart).toBe(2);
+ expect(textarea.selectionEnd).toBe(2);
+ });
+
+ it("clamps column when previous line is shorter", () => {
+ textarea.value = "Hi\nHello World";
+ textarea.selectionStart = 11; // "Hello Wo|rld"
+ textarea.selectionEnd = 11;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("k"));
+ });
+
+ // Previous line only has 2 chars, should clamp to end (position 2)
+ expect(textarea.selectionStart).toBe(2);
+ expect(textarea.selectionEnd).toBe(2);
+ });
+
+ it("stays on first line when already at first line", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 3; // "Lin|e 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("k"));
+ });
+
+ // Should stay at same position
+ expect(textarea.selectionStart).toBe(3);
+ expect(textarea.selectionEnd).toBe(3);
+ });
+
+ it("works with single line text (stays in place)", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("k"));
+ });
+
+ expect(textarea.selectionStart).toBe(3);
+ expect(textarea.selectionEnd).toBe(3);
+ });
+
+ it("handles empty lines", () => {
+ textarea.value = "Line 1\n\nLine 3";
+ textarea.selectionStart = 8; // "L|ine 3"
+ textarea.selectionEnd = 8;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("k"));
+ });
+
+ // Should go to empty line, column clamped to 0 (position 7)
+ expect(textarea.selectionStart).toBe(7);
+ expect(textarea.selectionEnd).toBe(7);
+ });
+ });
+
+ describe("0 (move to line start)", () => {
+ it("moves cursor to start of current line", () => {
+ textarea.value = "Hello, world!";
+ textarea.selectionStart = 7;
+ textarea.selectionEnd = 7;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("0"));
+ });
+
+ expect(textarea.selectionStart).toBe(0);
+ expect(textarea.selectionEnd).toBe(0);
+ });
+
+ it("stays at start when already at start", () => {
+ textarea.value = "Hello, world!";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("0"));
+ });
+
+ expect(textarea.selectionStart).toBe(0);
+ expect(textarea.selectionEnd).toBe(0);
+ });
+
+ it("works on second line of multiline text", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 10; // "Lin|e 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("0"));
+ });
+
+ expect(textarea.selectionStart).toBe(7); // Start of "Line 2"
+ expect(textarea.selectionEnd).toBe(7);
+ });
+
+ it("works on empty line", () => {
+ textarea.value = "Line 1\n\nLine 3";
+ textarea.selectionStart = 7; // On the empty line
+ textarea.selectionEnd = 7;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("0"));
+ });
+
+ expect(textarea.selectionStart).toBe(7);
+ expect(textarea.selectionEnd).toBe(7);
+ });
+
+ it("works from end of line", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 5;
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("0"));
+ });
+
+ expect(textarea.selectionStart).toBe(0);
+ expect(textarea.selectionEnd).toBe(0);
+ });
+ });
+
+ describe("$ (move to line end)", () => {
+ it("moves cursor to end of current line", () => {
+ textarea.value = "Hello, world!";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("$"));
+ });
+
+ expect(textarea.selectionStart).toBe(13);
+ expect(textarea.selectionEnd).toBe(13);
+ });
+
+ it("stays at end when already at end", () => {
+ textarea.value = "Hello, world!";
+ textarea.selectionStart = 13;
+ textarea.selectionEnd = 13;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("$"));
+ });
+
+ expect(textarea.selectionStart).toBe(13);
+ expect(textarea.selectionEnd).toBe(13);
+ });
+
+ it("works on middle line of multiline text", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 7; // "L|ine 2"
+ textarea.selectionEnd = 7;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("$"));
+ });
+
+ expect(textarea.selectionStart).toBe(13); // End of "Line 2" (before newline)
+ expect(textarea.selectionEnd).toBe(13);
+ });
+
+ it("works on empty line", () => {
+ textarea.value = "Line 1\n\nLine 3";
+ textarea.selectionStart = 7; // On the empty line
+ textarea.selectionEnd = 7;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("$"));
+ });
+
+ expect(textarea.selectionStart).toBe(7);
+ expect(textarea.selectionEnd).toBe(7);
+ });
+
+ it("works from start of line", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("$"));
+ });
+
+ expect(textarea.selectionStart).toBe(5);
+ expect(textarea.selectionEnd).toBe(5);
+ });
+
+ it("does not include newline character", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("$"));
+ });
+
+ // Should be at position 6 (end of "Line 1"), not 7 (the newline)
+ expect(textarea.selectionStart).toBe(6);
+ expect(textarea.selectionEnd).toBe(6);
+ });
+ });
+
+ describe("movement without textarea ref", () => {
+ it("does not crash when textareaRef is not provided", () => {
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: undefined })
+ );
+
+ const event = createKeyEvent("h");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ // Should prevent default but not crash
+ expect(event.defaultPrevented).toBe(true);
+ expect(result.current.mode).toBe("normal");
+ });
+
+ it("does not crash when textareaRef.current is null", () => {
+ const nullRef = {
+ current: null,
+ } as React.RefObject;
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: nullRef })
+ );
+
+ const event = createKeyEvent("j");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ // Should prevent default but not crash
+ expect(event.defaultPrevented).toBe(true);
+ expect(result.current.mode).toBe("normal");
+ });
+ });
+
+ describe("movement commands prevent default", () => {
+ it("h prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("h");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("j prevents default", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("j");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("k prevents default", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("k");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("l prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("l");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("0 prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("0");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("$ prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("$");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+});
+
+/**
+ * Insert mode entry command tests.
+ *
+ * @see REQ-9: Insert mode entry: i (before cursor), a (after cursor),
+ * A (end of line), o (new line below), O (new line above)
+ */
+describe("insert mode entry commands", () => {
+ let textarea: HTMLTextAreaElement;
+ let textareaRef: React.RefObject;
+
+ function createTextareaRef(ta: HTMLTextAreaElement) {
+ return { current: ta } as React.RefObject;
+ }
+
+ beforeEach(() => {
+ textarea = document.createElement("textarea");
+ document.body.appendChild(textarea);
+ textareaRef = createTextareaRef(textarea);
+ });
+
+ afterEach(() => {
+ textarea.remove();
+ });
+
+ function getOptionsWithTextarea(
+ onContentChange?: (content: string) => void
+ ): UseViModeOptions {
+ return {
+ enabled: true,
+ textareaRef,
+ onContentChange,
+ };
+ }
+
+ describe("'i' command (insert at cursor)", () => {
+ it("enters insert mode without moving cursor", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(2); // Cursor unchanged
+ expect(textarea.selectionEnd).toBe(2);
+ });
+
+ it("works at start of text", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("works at end of text", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 5;
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(5);
+ });
+
+ it("works with empty text", () => {
+ textarea.value = "";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(0);
+ });
+ });
+
+ describe("'a' command (append after cursor)", () => {
+ it("moves cursor right one position and enters insert mode", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("a"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(3); // Moved right
+ expect(textarea.selectionEnd).toBe(3);
+ });
+
+ it("positions at end when already at end", () => {
+ textarea.value = "Hi";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("a"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(2); // Stays at end (clamped)
+ });
+
+ it("works at start of text", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("a"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(1); // Moved to after first char
+ });
+
+ it("works with empty text", () => {
+ textarea.value = "";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("a"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(0); // Stays at 0 (clamped)
+ });
+ });
+
+ describe("'A' command (append at end of line)", () => {
+ it("moves cursor to end of current line", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 2; // "He|llo"
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("A"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(5); // End of "Hello"
+ expect(textarea.selectionEnd).toBe(5);
+ });
+
+ it("stays at end of single line text", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("A"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(5);
+ });
+
+ it("handles second line", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 8; // "Wo|rld"
+ textarea.selectionEnd = 8;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("A"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(11); // End of "World"
+ });
+
+ it("positions at start of empty line", () => {
+ textarea.value = "Hello\n\nWorld";
+ textarea.selectionStart = 6; // Empty line
+ textarea.selectionEnd = 6;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("A"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(6); // Same position (empty line)
+ });
+
+ it("works when already at end of line", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 5; // Already at end of "Hello"
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("A"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.selectionStart).toBe(5); // Stays at end
+ });
+ });
+
+ describe("'o' command (open line below)", () => {
+ it("inserts newline after current line and positions cursor", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 2; // "He|llo"
+ textarea.selectionEnd = 2;
+
+ let capturedContent = "";
+ const { result } = renderHook(() =>
+ useViMode(
+ getOptionsWithTextarea((content) => {
+ capturedContent = content;
+ })
+ )
+ );
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("o"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("Hello\n\nWorld"); // Newline inserted
+ expect(textarea.selectionStart).toBe(6); // Cursor on new blank line
+ expect(capturedContent).toBe("Hello\n\nWorld");
+ });
+
+ it("creates new last line", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("o"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("Hello\n");
+ expect(textarea.selectionStart).toBe(6); // After newline
+ });
+
+ it("works with cursor at end of line", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 6; // End of "Line 1"
+ textarea.selectionEnd = 6;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("o"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("Line 1\n\nLine 2");
+ expect(textarea.selectionStart).toBe(7);
+ });
+
+ it("works on last line of multi-line text", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 10; // "Lin|e 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("o"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("Line 1\nLine 2\n");
+ expect(textarea.selectionStart).toBe(14); // After final newline
+ });
+
+ it("works with empty text", () => {
+ textarea.value = "";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("o"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("\n");
+ expect(textarea.selectionStart).toBe(1);
+ });
+ });
+
+ describe("'O' command (open line above)", () => {
+ it("inserts newline before current line and positions cursor", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 8; // "Wo|rld"
+ textarea.selectionEnd = 8;
+
+ let capturedContent = "";
+ const { result } = renderHook(() =>
+ useViMode(
+ getOptionsWithTextarea((content) => {
+ capturedContent = content;
+ })
+ )
+ );
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("O"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("Hello\n\nWorld"); // Newline inserted before "World"
+ expect(textarea.selectionStart).toBe(6); // Cursor on new blank line
+ expect(capturedContent).toBe("Hello\n\nWorld");
+ });
+
+ it("creates new first line", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("O"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("\nHello");
+ expect(textarea.selectionStart).toBe(0); // At start of new blank line
+ });
+
+ it("works when on first line of multi-line text", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 3; // "Lin|e 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("O"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("\nLine 1\nLine 2");
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("works when cursor at start of line", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 6; // Start of "World"
+ textarea.selectionEnd = 6;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("O"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("Hello\n\nWorld");
+ expect(textarea.selectionStart).toBe(6); // On new blank line
+ });
+
+ it("works with empty text", () => {
+ textarea.value = "";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("O"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("\n");
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("handles middle line correctly", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 10; // "Lin|e 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("O"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(textarea.value).toBe("Line 1\n\nLine 2\nLine 3");
+ expect(textarea.selectionStart).toBe(7); // On new blank line
+ });
+ });
+
+ describe("insert mode entry prevents default", () => {
+ it("'i' prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("i");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("'a' prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("a");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("'A' prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("A");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("'o' prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("o");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("'O' prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("O");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+});
diff --git a/frontend/src/hooks/useViMode.ts b/frontend/src/hooks/useViMode.ts
index e1fb4c39..302dbd3f 100644
--- a/frontend/src/hooks/useViMode.ts
+++ b/frontend/src/hooks/useViMode.ts
@@ -2,12 +2,12 @@
* useViMode Hook - Vi Mode State Machine
*
* Implements core vi mode state management for the Pair Writing Editor.
- * Handles mode transitions between normal, insert, and command modes.
- *
- * This hook manages the state machine only. Actual cursor manipulation and
- * text operations are added in subsequent chunks.
+ * Handles mode transitions between normal, insert, and command modes,
+ * cursor movement commands, and insert mode entry with cursor positioning.
*
* @see .lore/plans/vi-mode-pair-writing.md (TD-1, TD-4)
+ * @see REQ-7, REQ-8: Movement commands (h, j, k, l, 0, $)
+ * @see REQ-9: Insert mode entry commands (i, a, A, o, O)
*/
import { useState, useCallback, useRef } from "react";
@@ -16,6 +16,7 @@ export type ViMode = "normal" | "insert" | "command";
export interface UseViModeOptions {
enabled: boolean;
+ textareaRef?: React.RefObject;
onSave?: () => void;
onExit?: () => void;
onQuitWithUnsaved?: () => void;
@@ -33,13 +34,129 @@ export interface UseViModeResult {
/**
* Keys that transition from normal mode to insert mode.
- * For now, all of these just change mode. Cursor positioning (a, A, o, O)
- * will be implemented in a later chunk.
+ * Each key positions the cursor differently before entering insert mode:
+ * - i: Insert at current cursor position
+ * - a: Insert after cursor (append)
+ * - A: Insert at end of line
+ * - o: Open new line below
+ * - O: Open new line above
*/
const INSERT_MODE_KEYS = new Set(["i", "a", "A", "o", "O"]);
+/**
+ * Movement command keys handled in normal mode.
+ */
+const MOVEMENT_KEYS = new Set(["h", "j", "k", "l", "0", "$"]);
+
+/**
+ * Information about the current line at a given cursor position.
+ */
+export interface LineInfo {
+ /** Zero-based line number */
+ lineNumber: number;
+ /** Index of the first character of this line in the text */
+ lineStart: number;
+ /** Index of the last character of this line (before newline or end of text) */
+ lineEnd: number;
+ /** Current column position within the line */
+ column: number;
+}
+
+/**
+ * Get information about the line at the given cursor position.
+ *
+ * @param text - The full text content
+ * @param position - The cursor position (index into text)
+ * @returns Information about the current line
+ */
+export function getLineInfo(text: string, position: number): LineInfo {
+ // Find line boundaries by scanning for newlines
+ let lineStart = 0;
+ let lineNumber = 0;
+
+ // Find the start of the current line
+ for (let i = 0; i < position; i++) {
+ if (text[i] === "\n") {
+ lineStart = i + 1;
+ lineNumber++;
+ }
+ }
+
+ // Find the end of the current line
+ let lineEnd = text.indexOf("\n", lineStart);
+ if (lineEnd === -1) {
+ lineEnd = text.length;
+ }
+
+ const column = position - lineStart;
+
+ return { lineNumber, lineStart, lineEnd, column };
+}
+
+/**
+ * Count the total number of lines in the text.
+ *
+ * @param text - The full text content
+ * @returns The number of lines (minimum 1)
+ */
+export function getLineCount(text: string): number {
+ if (text.length === 0) return 1;
+ let count = 1;
+ for (let i = 0; i < text.length; i++) {
+ if (text[i] === "\n") count++;
+ }
+ return count;
+}
+
+/**
+ * Get the start and end positions of a specific line.
+ *
+ * @param text - The full text content
+ * @param lineNumber - Zero-based line number
+ * @returns Object with lineStart and lineEnd, or null if line doesn't exist
+ */
+export function getLinePositions(
+ text: string,
+ lineNumber: number
+): { lineStart: number; lineEnd: number } | null {
+ let currentLine = 0;
+ let lineStart = 0;
+
+ for (let i = 0; i <= text.length; i++) {
+ if (currentLine === lineNumber) {
+ // Found the target line, now find its end
+ let lineEnd = text.indexOf("\n", lineStart);
+ if (lineEnd === -1) {
+ lineEnd = text.length;
+ }
+ return { lineStart, lineEnd };
+ }
+ if (i < text.length && text[i] === "\n") {
+ currentLine++;
+ lineStart = i + 1;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Move the cursor to a new position, collapsing any selection.
+ *
+ * @param textarea - The textarea element
+ * @param newPosition - The new cursor position
+ */
+export function moveCursor(
+ textarea: HTMLTextAreaElement,
+ newPosition: number
+): void {
+ const clamped = Math.max(0, Math.min(newPosition, textarea.value.length));
+ textarea.selectionStart = clamped;
+ textarea.selectionEnd = clamped;
+}
+
export function useViMode(options: UseViModeOptions): UseViModeResult {
- const { enabled } = options;
+ const { enabled, textareaRef, onContentChange } = options;
const [mode, setMode] = useState("normal");
const [commandBuffer, setCommandBuffer] = useState("");
@@ -57,10 +174,18 @@ export function useViMode(options: UseViModeOptions): UseViModeResult {
}
const { key } = e;
+ const textarea = textareaRef?.current ?? null;
switch (mode) {
case "normal":
- handleNormalModeKey(e, key, setMode, setCommandBuffer);
+ handleNormalModeKey(
+ e,
+ key,
+ setMode,
+ setCommandBuffer,
+ textarea,
+ onContentChange
+ );
break;
case "insert":
@@ -72,7 +197,7 @@ export function useViMode(options: UseViModeOptions): UseViModeResult {
break;
}
},
- [enabled, mode]
+ [enabled, mode, textareaRef, onContentChange]
);
return {
@@ -93,11 +218,16 @@ function handleNormalModeKey(
e: React.KeyboardEvent,
key: string,
setMode: React.Dispatch>,
- setCommandBuffer: React.Dispatch>
+ setCommandBuffer: React.Dispatch>,
+ textarea: HTMLTextAreaElement | null,
+ onContentChange?: (content: string) => void
): void {
- // Transition to insert mode
+ // Transition to insert mode with cursor positioning
if (INSERT_MODE_KEYS.has(key)) {
e.preventDefault();
+ if (textarea) {
+ executeInsertModeEntry(key, textarea, onContentChange);
+ }
setMode("insert");
return;
}
@@ -110,6 +240,13 @@ function handleNormalModeKey(
return;
}
+ // Handle movement commands if we have a textarea
+ if (MOVEMENT_KEYS.has(key) && textarea) {
+ e.preventDefault();
+ executeMovementCommand(key, textarea);
+ return;
+ }
+
// In normal mode, prevent default for all single-character keys to stop insertion
// Allow modifier keys and navigation keys to work naturally
if (key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
@@ -117,6 +254,162 @@ function handleNormalModeKey(
}
}
+/**
+ * Execute a movement command on the textarea.
+ *
+ * @param key - The movement key pressed
+ * @param textarea - The textarea element to manipulate
+ */
+function executeMovementCommand(
+ key: string,
+ textarea: HTMLTextAreaElement
+): void {
+ const text = textarea.value;
+ const pos = textarea.selectionStart;
+
+ switch (key) {
+ case "h": {
+ // Move left one character, clamp at 0
+ moveCursor(textarea, pos - 1);
+ break;
+ }
+ case "l": {
+ // Move right one character, clamp at end
+ moveCursor(textarea, pos + 1);
+ break;
+ }
+ case "j": {
+ // Move down one line, trying to maintain column position
+ const currentLine = getLineInfo(text, pos);
+ const totalLines = getLineCount(text);
+
+ // If already on the last line, stay put
+ if (currentLine.lineNumber >= totalLines - 1) {
+ return;
+ }
+
+ const nextLinePositions = getLinePositions(
+ text,
+ currentLine.lineNumber + 1
+ );
+ if (!nextLinePositions) return;
+
+ // Try to maintain column position, but clamp to next line's length
+ const nextLineLength =
+ nextLinePositions.lineEnd - nextLinePositions.lineStart;
+ const targetColumn = Math.min(currentLine.column, nextLineLength);
+ moveCursor(textarea, nextLinePositions.lineStart + targetColumn);
+ break;
+ }
+ case "k": {
+ // Move up one line, trying to maintain column position
+ const currentLine = getLineInfo(text, pos);
+
+ // If already on the first line, stay put
+ if (currentLine.lineNumber === 0) {
+ return;
+ }
+
+ const prevLinePositions = getLinePositions(
+ text,
+ currentLine.lineNumber - 1
+ );
+ if (!prevLinePositions) return;
+
+ // Try to maintain column position, but clamp to previous line's length
+ const prevLineLength =
+ prevLinePositions.lineEnd - prevLinePositions.lineStart;
+ const targetColumn = Math.min(currentLine.column, prevLineLength);
+ moveCursor(textarea, prevLinePositions.lineStart + targetColumn);
+ break;
+ }
+ case "0": {
+ // Move to start of current line
+ const currentLine = getLineInfo(text, pos);
+ moveCursor(textarea, currentLine.lineStart);
+ break;
+ }
+ case "$": {
+ // Move to end of current line
+ const currentLine = getLineInfo(text, pos);
+ moveCursor(textarea, currentLine.lineEnd);
+ break;
+ }
+ }
+}
+
+/**
+ * Execute an insert mode entry command, positioning the cursor appropriately.
+ *
+ * Commands:
+ * - `i`: Insert at current cursor position (no cursor movement needed)
+ * - `a`: Insert after current cursor position (move right one character)
+ * - `A`: Insert at end of current line
+ * - `o`: Open new line below current line, position cursor there
+ * - `O`: Open new line above current line, position cursor there
+ *
+ * @param key - The insert mode entry key pressed
+ * @param textarea - The textarea element to manipulate
+ * @param onContentChange - Optional callback for content changes (needed for o/O)
+ */
+function executeInsertModeEntry(
+ key: string,
+ textarea: HTMLTextAreaElement,
+ onContentChange?: (content: string) => void
+): void {
+ const text = textarea.value;
+ const pos = textarea.selectionStart;
+
+ switch (key) {
+ case "i": {
+ // Insert at current position - no cursor movement needed
+ // Cursor stays where it is
+ break;
+ }
+ case "a": {
+ // Insert after current position - move right one character
+ // Clamp at end of text (can position at the very end for appending)
+ const newPos = Math.min(pos + 1, text.length);
+ moveCursor(textarea, newPos);
+ break;
+ }
+ case "A": {
+ // Insert at end of current line
+ const currentLine = getLineInfo(text, pos);
+ moveCursor(textarea, currentLine.lineEnd);
+ break;
+ }
+ case "o": {
+ // Open new line below current line
+ const currentLine = getLineInfo(text, pos);
+ // Insert newline at end of current line
+ const insertPos = currentLine.lineEnd;
+ const newText =
+ text.slice(0, insertPos) + "\n" + text.slice(insertPos);
+ textarea.value = newText;
+ // Position cursor at start of new line (after the newline we just inserted)
+ moveCursor(textarea, insertPos + 1);
+ // Notify parent of content change
+ onContentChange?.(newText);
+ break;
+ }
+ case "O": {
+ // Open new line above current line
+ const currentLine = getLineInfo(text, pos);
+ // Insert newline at start of current line
+ const insertPos = currentLine.lineStart;
+ const newText =
+ text.slice(0, insertPos) + "\n" + text.slice(insertPos);
+ textarea.value = newText;
+ // Position cursor at the new blank line (which is now at lineStart)
+ moveCursor(textarea, insertPos);
+ // Notify parent of content change
+ onContentChange?.(newText);
+ break;
+ }
+ }
+}
+
/**
* Handle keystrokes in insert mode.
* In insert mode, we only intercept Escape to return to normal mode.
From 005a3b5b189ef210e287e052e01cc54abbe009ec Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 10:55:27 -0800
Subject: [PATCH 05/18] feat(vi-mode): Implement undo, delete, yank/put, and
numeric prefixes
Chunk 8 - Undo Stack:
- Add internal undo stack with 100 entry limit
- pushUndoState captures content and cursor before edits
- 'u' command restores previous state
- Insert mode batches into single undo entry
Chunk 9 - Delete Commands:
- 'x' deletes character at cursor
- 'dd' deletes current line (pending operator pattern)
- Both push to undo stack before delete
Chunk 10 - Yank/Put Commands:
- 'yy' yanks current line to internal clipboard
- 'p' pastes after current line
- 'P' pastes before current line
- Clipboard persists within session (REQ-20, REQ-21)
Chunk 11 - Numeric Prefixes:
- Digits 1-9 start count accumulation
- '0' is line-start when no count, digit when accumulating
- Count applies to: h, l, j, k, x, dd, yy
- Large counts clamp to boundaries
Completes Milestone C - full Normal mode editing now functional.
Co-Authored-By: Claude Opus 4.5
---
.lore/work/vi-mode-pair-writing.md | 23 +-
.../src/hooks/__tests__/useViMode.test.ts | 2426 ++++++++++++++++-
frontend/src/hooks/useViMode.ts | 587 +++-
3 files changed, 2968 insertions(+), 68 deletions(-)
diff --git a/.lore/work/vi-mode-pair-writing.md b/.lore/work/vi-mode-pair-writing.md
index 546c6b92..ed47f31f 100644
--- a/.lore/work/vi-mode-pair-writing.md
+++ b/.lore/work/vi-mode-pair-writing.md
@@ -96,10 +96,11 @@ Tasks:
- `O`: insert newline above, enter Insert
- Unit tests for cursor position after each
-### 8. Undo Stack
+### 8. Undo Stack ✓
**What**: Internal undo history for `u` command
**Delivers**: Can undo edit operations
**Depends on**: Chunk 3 (needs to hook into content changes)
+**Completed**: 2026-01-29
Tasks:
- Add undo stack to `useViMode` state
@@ -109,10 +110,11 @@ Tasks:
- Limit stack depth (100 entries)
- Unit tests for undo behavior
-### 9. Delete Commands
+### 9. Delete Commands ✓
**What**: `x` (character) and `dd` (line) deletion
**Delivers**: Can delete content in Normal mode
**Depends on**: Chunks 6, 8 (movement + undo)
+**Completed**: 2026-01-29
Tasks:
- Implement `x`: delete character at cursor
@@ -122,10 +124,11 @@ Tasks:
- Handle edge cases (empty line, end of doc)
- Unit tests
-### 10. Yank/Put Commands
+### 10. Yank/Put Commands ✓
**What**: `yy`, `p`, `P` with internal clipboard
**Delivers**: Copy/paste lines within editor
**Depends on**: Chunks 6, 8, 9 (movement, undo, line operations)
+**Completed**: 2026-01-29
Tasks:
- Add clipboard state to `useViMode`
@@ -135,10 +138,11 @@ Tasks:
- Push to undo stack before paste
- Unit tests
-### 11. Numeric Prefixes
+### 11. Numeric Prefixes ✓
**What**: Count prefix for commands (e.g., `5j`, `3dd`)
**Delivers**: Commands can be repeated with count
**Depends on**: Chunks 6, 9, 10 (needs commands to prefix)
+**Completed**: 2026-01-29
Tasks:
- Add `pendingCount` state to `useViMode`
@@ -209,10 +213,10 @@ Tasks:
5. ~~**Chunk 5** (Mode Indicator) - Quick UI win, depends on 3~~ ✓
6. ~~**Chunk 6** (Basic Movement) - First real vi behavior~~ ✓
7. ~~**Chunk 7** (Insert Entry) - Can now edit text~~ ✓
-8. **Chunk 8** (Undo Stack) - Safety net before destructive commands
-9. **Chunk 9** (Delete) - First destructive command
-10. **Chunk 10** (Yank/Put) - Copy/paste
-11. **Chunk 11** (Numeric Prefixes) - Power user feature
+8. ~~**Chunk 8** (Undo Stack) - Safety net before destructive commands~~ ✓
+9. ~~**Chunk 9** (Delete) - First destructive command~~ ✓
+10. ~~**Chunk 10** (Yank/Put) - Copy/paste~~ ✓
+11. ~~**Chunk 11** (Numeric Prefixes) - Power user feature~~ ✓
12. **Chunk 12** (Command Mode UI) - Needed for ex commands
13. **Chunk 13** (Ex Commands) - Save/exit flow
14. **Chunk 14** (Integration) - Wire it all together
@@ -226,6 +230,7 @@ Vi mode toggle works, shows mode indicator and block cursor. No commands yet, bu
**Milestone B (Chunks 6-7)**: ✓ COMPLETE (2026-01-29)
Navigation and insert mode work. Can move around and type text.
-**Milestone C (Chunks 8-11)**: Full Normal mode editing. Delete, yank, put, undo, counts all work.
+**Milestone C (Chunks 8-11)**: ✓ COMPLETE (2026-01-29)
+Full Normal mode editing. Delete, yank, put, undo, counts all work.
**Milestone D (Chunks 12-15)**: Complete feature. Ex commands for save/exit, full integration, polished.
diff --git a/frontend/src/hooks/__tests__/useViMode.test.ts b/frontend/src/hooks/__tests__/useViMode.test.ts
index 55867499..dee3e66c 100644
--- a/frontend/src/hooks/__tests__/useViMode.test.ts
+++ b/frontend/src/hooks/__tests__/useViMode.test.ts
@@ -2,13 +2,17 @@
* useViMode Hook Tests
*
* Tests the vi mode state machine: mode transitions between normal, insert, and command.
- * Also tests movement commands (h, j, k, l, 0, $) in normal mode.
+ * Also tests movement commands (h, j, k, l, 0, $), delete commands (x, dd),
+ * yank/put commands (yy, p, P), and undo.
*
* @see .lore/plans/vi-mode-pair-writing.md
* @see REQ-4: Support three modes: Normal (default), Insert, and Command
* @see REQ-6: Esc returns to Normal mode from Insert or Command mode
* @see REQ-7: Movement: h (left), j (down), k (up), l (right)
* @see REQ-8: Line movement: 0 (start of line), $ (end of line)
+ * @see REQ-10: Delete: x (character), dd (line)
+ * @see REQ-11: Yank/put: yy (copy line), p (paste after), P (paste before)
+ * @see REQ-12: Undo: u undoes last edit operation
*/
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
@@ -79,6 +83,12 @@ describe("useViMode", () => {
expect(result.current.clipboard).toBeNull();
});
+
+ it("starts with empty undo stack", () => {
+ const { result } = renderHook(() => useViMode(defaultOptions));
+
+ expect(result.current.undoStackSize).toBe(0);
+ });
});
describe("normal to insert mode transitions", () => {
@@ -1883,3 +1893,2417 @@ describe("insert mode entry commands", () => {
});
});
});
+
+/**
+ * Undo stack tests.
+ *
+ * @see REQ-12: Undo: u undoes last edit operation (maintains internal undo stack)
+ * @see TD-9: Undo stack implementation in .lore/plans/vi-mode-pair-writing.md
+ */
+describe("undo stack", () => {
+ let textarea: HTMLTextAreaElement;
+ let textareaRef: React.RefObject;
+
+ function createTextareaRef(ta: HTMLTextAreaElement) {
+ return { current: ta } as React.RefObject;
+ }
+
+ beforeEach(() => {
+ textarea = document.createElement("textarea");
+ document.body.appendChild(textarea);
+ textareaRef = createTextareaRef(textarea);
+ });
+
+ afterEach(() => {
+ textarea.remove();
+ });
+
+ function getOptionsWithTextarea(
+ onContentChange?: (content: string) => void
+ ): UseViModeOptions {
+ return {
+ enabled: true,
+ textareaRef,
+ onContentChange,
+ };
+ }
+
+ describe("pushUndoState", () => {
+ it("increases undo stack size when called", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ expect(result.current.undoStackSize).toBe(0);
+
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("pushes state before entering insert mode via 'i'", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ expect(result.current.undoStackSize).toBe(0);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("pushes state before entering insert mode via 'a'", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("a"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("pushes state before entering insert mode via 'A'", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("A"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("pushes state before 'o' command (which modifies content)", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("o"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("pushes state before 'O' command (which modifies content)", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("O"));
+ });
+
+ expect(result.current.mode).toBe("insert");
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("does not push state for movement commands", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("l"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+ });
+ });
+
+ describe("'u' command (undo)", () => {
+ it("prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("u");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("does nothing when undo stack is empty", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ // Content and cursor should be unchanged
+ expect(textarea.value).toBe("Hello");
+ expect(textarea.selectionStart).toBe(2);
+ expect(result.current.undoStackSize).toBe(0);
+ });
+
+ it("restores content from undo stack", () => {
+ textarea.value = "Original";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ let capturedContent = "";
+ const { result } = renderHook(() =>
+ useViMode(
+ getOptionsWithTextarea((content) => {
+ capturedContent = content;
+ })
+ )
+ );
+
+ // Push the original state to undo stack
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ // Simulate an edit (in real usage, this would be insert mode changes)
+ textarea.value = "Modified";
+ textarea.selectionStart = 5;
+ textarea.selectionEnd = 5;
+
+ // Now undo
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("Original");
+ expect(capturedContent).toBe("Original");
+ });
+
+ it("restores cursor position from undo stack", () => {
+ textarea.value = "Hello World";
+ textarea.selectionStart = 5;
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Push state with cursor at position 5
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ // Move cursor
+ textarea.selectionStart = 8;
+ textarea.selectionEnd = 8;
+
+ // Undo should restore cursor to position 5
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.selectionStart).toBe(5);
+ expect(textarea.selectionEnd).toBe(5);
+ });
+
+ it("decreases undo stack size when undo is performed", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Push two states
+ act(() => {
+ result.current.pushUndoState();
+ });
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ expect(result.current.undoStackSize).toBe(2);
+
+ // Undo once
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(result.current.undoStackSize).toBe(1);
+
+ // Undo again
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+ });
+
+ it("supports multiple consecutive undos", () => {
+ textarea.value = "State 1";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Push State 1
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ // Change to State 2
+ textarea.value = "State 2";
+ textarea.selectionStart = 1;
+ textarea.selectionEnd = 1;
+
+ // Push State 2
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ // Change to State 3
+ textarea.value = "State 3";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ // First undo: back to State 2
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("State 2");
+ expect(textarea.selectionStart).toBe(1);
+
+ // Second undo: back to State 1
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("State 1");
+ expect(textarea.selectionStart).toBe(0);
+
+ // Third undo: stack is empty, no change
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("State 1");
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("undoes 'o' command modifications", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Enter insert mode via 'o' (this pushes undo state first)
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("o"));
+ });
+
+ expect(textarea.value).toBe("Hello\n");
+ expect(result.current.mode).toBe("insert");
+
+ // Exit insert mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Escape"));
+ });
+
+ expect(result.current.mode).toBe("normal");
+
+ // Undo should restore original content
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("Hello");
+ expect(textarea.selectionStart).toBe(3);
+ });
+
+ it("undoes 'O' command modifications", () => {
+ textarea.value = "World";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Enter insert mode via 'O' (this pushes undo state first)
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("O"));
+ });
+
+ expect(textarea.value).toBe("\nWorld");
+ expect(result.current.mode).toBe("insert");
+
+ // Exit insert mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Escape"));
+ });
+
+ // Undo should restore original content
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("World");
+ expect(textarea.selectionStart).toBe(2);
+ });
+
+ it("batches insert mode changes into single undo entry", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 5;
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Enter insert mode (pushes undo state)
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("a"));
+ });
+
+ expect(result.current.undoStackSize).toBe(1);
+
+ // Simulate typing multiple characters in insert mode
+ // (In real usage, the browser handles this, we just simulate the result)
+ textarea.value = "Hello World";
+ textarea.selectionStart = 11;
+ textarea.selectionEnd = 11;
+
+ // Exit insert mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("Escape"));
+ });
+
+ // Stack size should still be 1 (no new entries during insert mode)
+ expect(result.current.undoStackSize).toBe(1);
+
+ // Single undo should restore the entire insert mode session
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("Hello");
+ expect(result.current.undoStackSize).toBe(0);
+ });
+ });
+
+ describe("undo stack depth limit", () => {
+ it("limits stack to 100 entries", () => {
+ textarea.value = "Test";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Push 105 states
+ for (let i = 0; i < 105; i++) {
+ act(() => {
+ result.current.pushUndoState();
+ });
+ }
+
+ // Stack should be capped at 100
+ expect(result.current.undoStackSize).toBe(100);
+ });
+
+ it("removes oldest entries when limit exceeded", () => {
+ textarea.value = "Entry 0";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Push first state (Entry 0)
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ // Push 100 more states (101 total, limit is 100)
+ for (let i = 1; i <= 100; i++) {
+ textarea.value = `Entry ${i}`;
+ textarea.selectionStart = i;
+ textarea.selectionEnd = i;
+ act(() => {
+ result.current.pushUndoState();
+ });
+ }
+
+ // Set to final state
+ textarea.value = "Final";
+ textarea.selectionStart = 101;
+ textarea.selectionEnd = 101;
+
+ // Stack should have 100 entries, oldest (Entry 0) should be removed
+ expect(result.current.undoStackSize).toBe(100);
+
+ // Undo 100 times - should get to "Entry 1" (Entry 0 was removed)
+ for (let i = 0; i < 100; i++) {
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+ }
+
+ // After 100 undos, we should be at the oldest remaining entry
+ // The oldest entry is "Entry 1" (Entry 0 was removed when we exceeded 100)
+ expect(textarea.value).toBe("Entry 1");
+ expect(result.current.undoStackSize).toBe(0);
+ });
+ });
+
+ describe("undo without textarea", () => {
+ it("does not crash when textareaRef is not provided", () => {
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: undefined })
+ );
+
+ // Push should not crash
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ // Undo should not crash
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+ });
+
+ it("does not crash when textareaRef.current is null", () => {
+ const nullRef = {
+ current: null,
+ } as React.RefObject;
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: nullRef })
+ );
+
+ // Push should not crash
+ act(() => {
+ result.current.pushUndoState();
+ });
+
+ // Undo should not crash
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+ });
+ });
+});
+
+/**
+ * Delete command tests.
+ *
+ * @see REQ-10: Delete: x (character), dd (line)
+ */
+describe("delete commands", () => {
+ let textarea: HTMLTextAreaElement;
+ let textareaRef: React.RefObject;
+
+ function createTextareaRef(ta: HTMLTextAreaElement) {
+ return { current: ta } as React.RefObject;
+ }
+
+ beforeEach(() => {
+ textarea = document.createElement("textarea");
+ document.body.appendChild(textarea);
+ textareaRef = createTextareaRef(textarea);
+ });
+
+ afterEach(() => {
+ textarea.remove();
+ });
+
+ function getOptionsWithTextarea(
+ onContentChange?: (content: string) => void
+ ): UseViModeOptions {
+ return {
+ enabled: true,
+ textareaRef,
+ onContentChange,
+ };
+ }
+
+ describe("'x' command (delete character)", () => {
+ it("deletes the character at cursor position", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ let capturedContent = "";
+ const { result } = renderHook(() =>
+ useViMode(
+ getOptionsWithTextarea((content) => {
+ capturedContent = content;
+ })
+ )
+ );
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("Helo");
+ expect(capturedContent).toBe("Helo");
+ });
+
+ it("prevents default", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("x");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("keeps cursor at same position after delete", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.selectionStart).toBe(2);
+ expect(textarea.selectionEnd).toBe(2);
+ });
+
+ it("clamps cursor when deleting last character", () => {
+ textarea.value = "Hi";
+ textarea.selectionStart = 1;
+ textarea.selectionEnd = 1;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("H");
+ expect(textarea.selectionStart).toBe(1); // Clamped to end
+ });
+
+ it("does nothing when cursor is at end of text", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 5;
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("Hello");
+ expect(textarea.selectionStart).toBe(5);
+ });
+
+ it("does nothing on empty text", () => {
+ textarea.value = "";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("");
+ });
+
+ it("pushes to undo stack before delete", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ expect(result.current.undoStackSize).toBe(0);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("does not push to undo stack when nothing to delete", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 5; // At end
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+ });
+
+ it("can be undone with 'u'", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("Helo");
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("Hello");
+ expect(textarea.selectionStart).toBe(2);
+ });
+
+ it("deletes newline character when cursor is on it", () => {
+ textarea.value = "Hello\nWorld";
+ textarea.selectionStart = 5; // On the newline
+ textarea.selectionEnd = 5;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("HelloWorld");
+ });
+
+ it("deletes first character when at position 0", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("ello");
+ expect(textarea.selectionStart).toBe(0);
+ });
+ });
+
+ describe("'dd' command (delete line)", () => {
+ it("sets pending operator on first 'd' press", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(result.current.pendingOperator).toBe("d");
+ expect(textarea.value).toBe("Hello"); // No change yet
+ });
+
+ it("prevents default on 'd' press", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("d");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("deletes line on 'dd' sequence", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 10; // In "Line 2"
+ textarea.selectionEnd = 10;
+
+ let capturedContent = "";
+ const { result } = renderHook(() =>
+ useViMode(
+ getOptionsWithTextarea((content) => {
+ capturedContent = content;
+ })
+ )
+ );
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("Line 1\nLine 3");
+ expect(capturedContent).toBe("Line 1\nLine 3");
+ });
+
+ it("clears pending operator after dd execution", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(result.current.pendingOperator).toBeNull();
+ });
+
+ it("positions cursor at start of next line after delete", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 10; // In "Line 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ // Cursor should be at start of what was "Line 3" (now at position 7)
+ expect(textarea.selectionStart).toBe(7);
+ });
+
+ it("deletes last line and positions cursor at previous line", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 10; // In "Line 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("Line 1");
+ // Cursor should be at start of Line 1
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("deletes only line leaving empty document", () => {
+ textarea.value = "Only line";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("");
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("deletes first line of multi-line text", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("Line 2\nLine 3");
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("deletes empty line", () => {
+ textarea.value = "Line 1\n\nLine 3";
+ textarea.selectionStart = 7; // On empty line
+ textarea.selectionEnd = 7;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("Line 1\nLine 3");
+ });
+
+ it("does nothing on empty document", () => {
+ textarea.value = "";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("");
+ });
+
+ it("pushes to undo stack before delete", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ expect(result.current.undoStackSize).toBe(0);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ // First 'd' should not push undo state yet
+ expect(result.current.undoStackSize).toBe(0);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ // Second 'd' (execution) should push undo state
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("does not push undo state on empty document", () => {
+ textarea.value = "";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+ });
+
+ it("can be undone with 'u'", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 10; // In "Line 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("Line 1\nLine 3");
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("Line 1\nLine 2\nLine 3");
+ expect(textarea.selectionStart).toBe(10);
+ });
+
+ it("clears pending operator when other key is pressed", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(result.current.pendingOperator).toBe("d");
+
+ // Press a different key
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(result.current.pendingOperator).toBeNull();
+ });
+
+ it("clears pending operator on insert mode entry", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(result.current.pendingOperator).toBe("d");
+
+ // Enter insert mode
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ expect(result.current.pendingOperator).toBeNull();
+ expect(result.current.mode).toBe("insert");
+ });
+ });
+
+ describe("delete without textarea", () => {
+ it("'x' does not crash when textareaRef is null", () => {
+ const nullRef = {
+ current: null,
+ } as React.RefObject;
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: nullRef })
+ );
+
+ const event = createKeyEvent("x");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ // Should prevent default but not crash
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("'dd' does not crash when textareaRef is null", () => {
+ const nullRef = {
+ current: null,
+ } as React.RefObject;
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: nullRef })
+ );
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ // Should not crash
+ expect(result.current.mode).toBe("normal");
+ });
+ });
+});
+
+/**
+ * Yank/Put command tests.
+ *
+ * @see REQ-11: Yank/put: yy (copy line), p (paste after), P (paste before)
+ * @see REQ-20: Yank/put operations use internal clipboard (separate from system)
+ * @see REQ-21: Clipboard persists within Pair Writing session
+ */
+describe("yank/put commands", () => {
+ let textarea: HTMLTextAreaElement;
+ let textareaRef: React.RefObject;
+
+ function createTextareaRef(ta: HTMLTextAreaElement) {
+ return { current: ta } as React.RefObject;
+ }
+
+ beforeEach(() => {
+ textarea = document.createElement("textarea");
+ document.body.appendChild(textarea);
+ textareaRef = createTextareaRef(textarea);
+ });
+
+ afterEach(() => {
+ textarea.remove();
+ });
+
+ function getOptionsWithTextarea(
+ onContentChange?: (content: string) => void
+ ): UseViModeOptions {
+ return {
+ enabled: true,
+ textareaRef,
+ onContentChange,
+ };
+ }
+
+ describe("'yy' command (yank line)", () => {
+ it("sets pending operator on first 'y' press", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.pendingOperator).toBe("y");
+ expect(textarea.value).toBe("Hello"); // No change
+ });
+
+ it("prevents default on 'y' press", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("y");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("yanks line to clipboard on 'yy' sequence", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 10; // In "Line 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("Line 2");
+ expect(textarea.value).toBe("Line 1\nLine 2\nLine 3"); // No change to text
+ });
+
+ it("clears pending operator after yy execution", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.pendingOperator).toBeNull();
+ });
+
+ it("yanks empty line", () => {
+ textarea.value = "Line 1\n\nLine 3";
+ textarea.selectionStart = 7; // On empty line
+ textarea.selectionEnd = 7;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("");
+ });
+
+ it("yanks first line", () => {
+ textarea.value = "First\nSecond";
+ textarea.selectionStart = 2; // In "First"
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("First");
+ });
+
+ it("yanks last line", () => {
+ textarea.value = "First\nLast";
+ textarea.selectionStart = 8; // In "Last"
+ textarea.selectionEnd = 8;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("Last");
+ });
+
+ it("clipboard persists across multiple yanks (last yank wins)", () => {
+ textarea.value = "Line A\nLine B\nLine C";
+ textarea.selectionStart = 3; // In "Line A"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank Line A
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("Line A");
+
+ // Move to Line B and yank
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("Line B");
+ });
+
+ it("does not push to undo stack (yank is non-destructive)", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+ });
+ });
+
+ describe("'p' command (paste after)", () => {
+ it("pastes clipboard content after current line", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ let capturedContent = "";
+ const { result } = renderHook(() =>
+ useViMode(
+ getOptionsWithTextarea((content) => {
+ capturedContent = content;
+ })
+ )
+ );
+
+ // First yank Line 2
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ // Move back to Line 1 and paste after
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("p"));
+ });
+
+ expect(textarea.value).toBe("Line 1\nLine 2\nLine 2\nLine 3");
+ expect(capturedContent).toBe("Line 1\nLine 2\nLine 2\nLine 3");
+ });
+
+ it("prevents default on 'p' press", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("p");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("positions cursor at start of pasted line", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank Line 2
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ // Move to Line 1 and paste
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("p"));
+ });
+
+ // Cursor should be at start of pasted line (after "Line 1\n")
+ expect(textarea.selectionStart).toBe(7);
+ });
+
+ it("does nothing with empty clipboard", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // No yank, clipboard is null
+ expect(result.current.clipboard).toBeNull();
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("p"));
+ });
+
+ // Content unchanged
+ expect(textarea.value).toBe("Line 1\nLine 2");
+ });
+
+ it("pushes to undo stack before paste", () => {
+ textarea.value = "Line 1";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank current line
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+
+ // Paste
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("p"));
+ });
+
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("paste on last line creates new last line", () => {
+ textarea.value = "First\nLast";
+ textarea.selectionStart = 8; // In "Last"
+ textarea.selectionEnd = 8;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank "First"
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ // Move to Last and paste after
+ textarea.selectionStart = 8;
+ textarea.selectionEnd = 8;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("p"));
+ });
+
+ expect(textarea.value).toBe("First\nLast\nFirst");
+ });
+
+ it("can undo paste operation", () => {
+ textarea.value = "Original";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank and paste
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("p"));
+ });
+
+ expect(textarea.value).toBe("Original\nOriginal");
+
+ // Undo
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("Original");
+ });
+ });
+
+ describe("'P' command (paste before)", () => {
+ it("pastes clipboard content before current line", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 10; // In "Line 2"
+ textarea.selectionEnd = 10;
+
+ let capturedContent = "";
+ const { result } = renderHook(() =>
+ useViMode(
+ getOptionsWithTextarea((content) => {
+ capturedContent = content;
+ })
+ )
+ );
+
+ // First yank Line 1
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ // Move to Line 2 and paste before
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("P"));
+ });
+
+ expect(textarea.value).toBe("Line 1\nLine 1\nLine 2\nLine 3");
+ expect(capturedContent).toBe("Line 1\nLine 1\nLine 2\nLine 3");
+ });
+
+ it("prevents default on 'P' press", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("P");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("positions cursor at start of pasted line", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 10; // In "Line 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank Line 1
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ // Move to Line 2 and paste before
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("P"));
+ });
+
+ // Cursor should be at start of pasted line (at "Line 1" insertion point)
+ expect(textarea.selectionStart).toBe(7);
+ });
+
+ it("does nothing with empty clipboard", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // No yank, clipboard is null
+ expect(result.current.clipboard).toBeNull();
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("P"));
+ });
+
+ // Content unchanged
+ expect(textarea.value).toBe("Line 1\nLine 2");
+ });
+
+ it("pushes to undo stack before paste", () => {
+ textarea.value = "Line 1";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank current line
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.undoStackSize).toBe(0);
+
+ // Paste before
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("P"));
+ });
+
+ expect(result.current.undoStackSize).toBe(1);
+ });
+
+ it("paste on first line creates new first line", () => {
+ textarea.value = "First\nSecond";
+ textarea.selectionStart = 2; // In "First"
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank "Second"
+ textarea.selectionStart = 8;
+ textarea.selectionEnd = 8;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ // Move to First and paste before
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("P"));
+ });
+
+ expect(textarea.value).toBe("Second\nFirst\nSecond");
+ });
+
+ it("can undo paste operation", () => {
+ textarea.value = "Original";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank and paste before
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("P"));
+ });
+
+ expect(textarea.value).toBe("Original\nOriginal");
+
+ // Undo
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ expect(textarea.value).toBe("Original");
+ });
+ });
+
+ describe("pending operator interaction", () => {
+ it("other keys clear pending 'y' operator", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.pendingOperator).toBe("y");
+
+ // Press 'h' (movement key)
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(result.current.pendingOperator).toBeNull();
+ });
+
+ it("'y' then 'd' starts delete operator (not yank)", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.pendingOperator).toBe("y");
+
+ // Press 'd' - should start delete operator
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(result.current.pendingOperator).toBe("d");
+ });
+
+ it("'d' then 'y' starts yank operator (not delete)", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(result.current.pendingOperator).toBe("d");
+
+ // Press 'y' - should start yank operator
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.pendingOperator).toBe("y");
+ });
+ });
+
+ describe("yank/put without textarea", () => {
+ it("'yy' does not crash when textareaRef is null", () => {
+ const nullRef = {
+ current: null,
+ } as React.RefObject;
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: nullRef })
+ );
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ // Should not crash
+ expect(result.current.mode).toBe("normal");
+ });
+
+ it("'p' does not crash when textareaRef is null", () => {
+ const nullRef = {
+ current: null,
+ } as React.RefObject;
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: nullRef })
+ );
+
+ const event = createKeyEvent("p");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ // Should prevent default but not crash
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("'P' does not crash when textareaRef is null", () => {
+ const nullRef = {
+ current: null,
+ } as React.RefObject;
+ const { result } = renderHook(() =>
+ useViMode({ enabled: true, textareaRef: nullRef })
+ );
+
+ const event = createKeyEvent("P");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ // Should prevent default but not crash
+ expect(event.defaultPrevented).toBe(true);
+ });
+ });
+});
+
+/**
+ * Numeric prefix tests.
+ *
+ * @see REQ-13: Numeric prefixes for command repetition (e.g., 5j, 3dd, 2x)
+ * @see TD-8: Numeric prefix handling
+ */
+describe("numeric prefixes", () => {
+ let textarea: HTMLTextAreaElement;
+ let textareaRef: React.RefObject;
+
+ function createTextareaRef(ta: HTMLTextAreaElement) {
+ return { current: ta } as React.RefObject;
+ }
+
+ beforeEach(() => {
+ textarea = document.createElement("textarea");
+ document.body.appendChild(textarea);
+ textareaRef = createTextareaRef(textarea);
+ });
+
+ afterEach(() => {
+ textarea.remove();
+ });
+
+ function getOptionsWithTextarea(
+ onContentChange?: (content: string) => void
+ ): UseViModeOptions {
+ return {
+ enabled: true,
+ textareaRef,
+ onContentChange,
+ };
+ }
+
+ describe("count accumulation", () => {
+ it("starts with null pending count", () => {
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ expect(result.current.pendingCount).toBeNull();
+ });
+
+ it("accumulates digits 1-9 as count", () => {
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("5"));
+ });
+
+ expect(result.current.pendingCount).toBe(5);
+ });
+
+ it("accumulates multiple digits", () => {
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("1"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("2"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("3"));
+ });
+
+ expect(result.current.pendingCount).toBe(123);
+ });
+
+ it("treats 0 as movement command when no count pending", () => {
+ textarea.value = "Hello, world!";
+ textarea.selectionStart = 7;
+ textarea.selectionEnd = 7;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("0"));
+ });
+
+ // Should move to line start, not set count
+ expect(result.current.pendingCount).toBeNull();
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("treats 0 as digit when count is pending (10j case)", () => {
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("1"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("0"));
+ });
+
+ expect(result.current.pendingCount).toBe(10);
+ });
+
+ it("prevents default for digit keys", () => {
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ const event = createKeyEvent("5");
+ act(() => {
+ result.current.handleKeyDown(event);
+ });
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("clears count on Escape", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("5"));
+ });
+ expect(result.current.pendingCount).toBe(5);
+
+ // Enter insert mode to test Escape clearing
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("i"));
+ });
+
+ // Count should be cleared when entering insert mode
+ expect(result.current.pendingCount).toBeNull();
+ });
+
+ it("clears count after command execution", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 4;
+ textarea.selectionEnd = 4;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("2"));
+ });
+ expect(result.current.pendingCount).toBe(2);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(result.current.pendingCount).toBeNull();
+ expect(textarea.selectionStart).toBe(2); // Moved 2 left
+ });
+ });
+
+ describe("movement with count", () => {
+ it("5h moves left 5 characters", () => {
+ textarea.value = "Hello, world!";
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("5"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(textarea.selectionStart).toBe(5);
+ });
+
+ it("5l moves right 5 characters", () => {
+ textarea.value = "Hello, world!";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("5"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("l"));
+ });
+
+ expect(textarea.selectionStart).toBe(5);
+ });
+
+ it("3j moves down 3 lines", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("3"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("j"));
+ });
+
+ // Should be on Line 4, column 3
+ expect(textarea.selectionStart).toBe(24); // "Line 1\nLine 2\nLine 3\n" = 21, + 3 = 24
+ });
+
+ it("3k moves up 3 lines", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
+ textarea.selectionStart = 24; // In "Line 4"
+ textarea.selectionEnd = 24;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("3"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("k"));
+ });
+
+ // Should be on Line 1, column 3
+ expect(textarea.selectionStart).toBe(3);
+ });
+
+ it("clamps h movement at beginning of text", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("clamps l movement at end of text", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("l"));
+ });
+
+ expect(textarea.selectionStart).toBe(5);
+ });
+
+ it("j stops at last line when count exceeds available lines", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("j"));
+ });
+
+ // Should be on Line 3 (last line), column 3
+ expect(textarea.selectionStart).toBe(17);
+ });
+
+ it("k stops at first line when count exceeds available lines", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 17; // In "Line 3"
+ textarea.selectionEnd = 17;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("k"));
+ });
+
+ // Should be on Line 1 (first line), column 3
+ expect(textarea.selectionStart).toBe(3);
+ });
+ });
+
+ describe("delete with count", () => {
+ it("3x deletes 3 characters", () => {
+ textarea.value = "Hello, world!";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("3"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("lo, world!");
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("x clamps delete count to available characters", () => {
+ textarea.value = "Hi";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+
+ expect(textarea.value).toBe("");
+ });
+
+ it("3dd deletes 3 lines", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("3"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("Line 4\nLine 5");
+ expect(textarea.selectionStart).toBe(0);
+ });
+
+ it("dd clamps line count to available lines", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("");
+ });
+
+ it("dd with count from middle of document", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
+ textarea.selectionStart = 10; // In "Line 2"
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("2"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ expect(textarea.value).toBe("Line 1\nLine 4\nLine 5");
+ });
+
+ it("dd with count from near end of document", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 17; // In "Line 3"
+ textarea.selectionEnd = 17;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("5"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+
+ // Should delete just Line 3 (only 1 line available)
+ expect(textarea.value).toBe("Line 1\nLine 2");
+ });
+ });
+
+ describe("yank with count", () => {
+ it("3yy yanks 3 lines", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("3"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("Line 1\nLine 2\nLine 3");
+ // Text should be unchanged
+ expect(textarea.value).toBe("Line 1\nLine 2\nLine 3\nLine 4\nLine 5");
+ });
+
+ it("yy clamps line count to available lines", () => {
+ textarea.value = "Line 1\nLine 2";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("Line 1\nLine 2");
+ });
+
+ it("multi-line yank and paste works correctly", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 3; // In "Line 1"
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Yank 2 lines
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("2"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+
+ expect(result.current.clipboard).toBe("Line 1\nLine 2");
+
+ // Move to Line 3 and paste after
+ textarea.selectionStart = 17;
+ textarea.selectionEnd = 17;
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("p"));
+ });
+
+ expect(textarea.value).toBe("Line 1\nLine 2\nLine 3\nLine 1\nLine 2");
+ });
+ });
+
+ describe("count with pending operator", () => {
+ it("count persists across first d key", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("2"));
+ });
+ expect(result.current.pendingCount).toBe(2);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ // Count should still be there, pending operator set
+ expect(result.current.pendingOperator).toBe("d");
+ expect(result.current.pendingCount).toBe(2);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("d"));
+ });
+ // Now both should be cleared
+ expect(result.current.pendingOperator).toBeNull();
+ expect(result.current.pendingCount).toBeNull();
+ expect(textarea.value).toBe("Line 3");
+ });
+
+ it("count persists across first y key", () => {
+ textarea.value = "Line 1\nLine 2\nLine 3";
+ textarea.selectionStart = 3;
+ textarea.selectionEnd = 3;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("2"));
+ });
+ expect(result.current.pendingCount).toBe(2);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ // Count should still be there, pending operator set
+ expect(result.current.pendingOperator).toBe("y");
+ expect(result.current.pendingCount).toBe(2);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("y"));
+ });
+ // Now both should be cleared
+ expect(result.current.pendingOperator).toBeNull();
+ expect(result.current.pendingCount).toBeNull();
+ expect(result.current.clipboard).toBe("Line 1\nLine 2");
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles very large counts gracefully", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 2;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Type 999
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("9"));
+ });
+
+ expect(result.current.pendingCount).toBe(999);
+
+ // Move left should clamp to start
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(textarea.selectionStart).toBe(0);
+ expect(result.current.pendingCount).toBeNull();
+ });
+
+ it("count clears when entering command mode", () => {
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("5"));
+ });
+ expect(result.current.pendingCount).toBe(5);
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent(":"));
+ });
+
+ expect(result.current.mode).toBe("command");
+ expect(result.current.pendingCount).toBeNull();
+ });
+
+ it("count does not apply to 0 movement (0 is always line start)", () => {
+ textarea.value = " Hello, world!";
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Start a count, then press 0
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("5"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("0"));
+ });
+
+ // Should have count 50 now, not moved
+ expect(result.current.pendingCount).toBe(50);
+ expect(textarea.selectionStart).toBe(10);
+
+ // Now press h to use the count
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("h"));
+ });
+
+ expect(textarea.selectionStart).toBe(0); // Clamped from 10-50
+ expect(result.current.pendingCount).toBeNull();
+ });
+
+ it("undo does not use count (just undoes once)", () => {
+ textarea.value = "Hello";
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ const { result } = renderHook(() => useViMode(getOptionsWithTextarea()));
+
+ // Delete two characters separately to have two undo states
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+ expect(textarea.value).toBe("ello");
+
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("x"));
+ });
+ expect(textarea.value).toBe("llo");
+
+ // Try 2u - should only undo once (count ignored for u in our impl)
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("2"));
+ });
+ act(() => {
+ result.current.handleKeyDown(createKeyEvent("u"));
+ });
+
+ // Should only undo once
+ expect(textarea.value).toBe("ello");
+ });
+ });
+});
diff --git a/frontend/src/hooks/useViMode.ts b/frontend/src/hooks/useViMode.ts
index 302dbd3f..869ea92a 100644
--- a/frontend/src/hooks/useViMode.ts
+++ b/frontend/src/hooks/useViMode.ts
@@ -3,17 +3,35 @@
*
* Implements core vi mode state management for the Pair Writing Editor.
* Handles mode transitions between normal, insert, and command modes,
- * cursor movement commands, and insert mode entry with cursor positioning.
+ * cursor movement commands, insert mode entry with cursor positioning,
+ * internal undo stack for the `u` command, delete commands, yank/put,
+ * and numeric prefixes for command repetition.
*
- * @see .lore/plans/vi-mode-pair-writing.md (TD-1, TD-4)
+ * @see .lore/plans/vi-mode-pair-writing.md (TD-1, TD-4, TD-8, TD-9)
* @see REQ-7, REQ-8: Movement commands (h, j, k, l, 0, $)
* @see REQ-9: Insert mode entry commands (i, a, A, o, O)
+ * @see REQ-10: Delete commands (x, dd)
+ * @see REQ-11: Yank/put commands (yy, p, P)
+ * @see REQ-12: Undo: u undoes last edit operation
+ * @see REQ-13: Numeric prefixes (e.g., 5j, 3dd, 2x)
*/
-import { useState, useCallback, useRef } from "react";
+import { useState, useCallback } from "react";
export type ViMode = "normal" | "insert" | "command";
+/**
+ * Snapshot of editor state for undo functionality.
+ * Captures both content and cursor position to restore both on undo.
+ */
+export interface UndoState {
+ content: string;
+ cursorPosition: number;
+}
+
+/** Maximum number of undo entries to keep. Prevents unbounded memory growth. */
+const MAX_UNDO_STACK_SIZE = 100;
+
export interface UseViModeOptions {
enabled: boolean;
textareaRef?: React.RefObject;
@@ -30,6 +48,10 @@ export interface UseViModeResult {
pendingCount: number | null;
pendingOperator: "d" | "y" | null;
clipboard: string | null;
+ /** Current undo stack depth (for debugging/testing) */
+ undoStackSize: number;
+ /** Push current state to undo stack (exposed for external edit operations) */
+ pushUndoState: () => void;
}
/**
@@ -161,10 +183,75 @@ export function useViMode(options: UseViModeOptions): UseViModeResult {
const [mode, setMode] = useState("normal");
const [commandBuffer, setCommandBuffer] = useState("");
- // These will be used in later chunks for commands/operations
- const pendingCountRef = useRef(null);
- const pendingOperatorRef = useRef<"d" | "y" | null>(null);
- const clipboardRef = useRef(null);
+ // Pending operator for multi-key commands like 'dd', 'yy'
+ // Using state so changes trigger re-renders and can be observed by tests
+ const [pendingOperator, setPendingOperator] = useState<"d" | "y" | null>(
+ null
+ );
+
+ // Pending count for numeric prefixes (e.g., 5j, 3dd)
+ // Using state so changes trigger re-renders and can be observed by tests
+ const [pendingCount, setPendingCount] = useState(null);
+
+ // Internal clipboard for yank/put operations (REQ-20, REQ-21)
+ // Using state so clipboard is observable in tests and persists within session
+ const [clipboard, setClipboard] = useState(null);
+
+ // Undo stack: stores snapshots of content + cursor position
+ // Using state instead of ref so that undoStackSize triggers re-renders
+ const [undoStack, setUndoStack] = useState([]);
+
+ /**
+ * Push current editor state to undo stack.
+ * Called before any edit operation to enable undoing.
+ * Stack is capped at MAX_UNDO_STACK_SIZE to prevent memory issues.
+ */
+ const pushUndoState = useCallback(() => {
+ const textarea = textareaRef?.current;
+ if (!textarea) return;
+
+ const state: UndoState = {
+ content: textarea.value,
+ cursorPosition: textarea.selectionStart,
+ };
+
+ setUndoStack((prev) => {
+ const newStack = [...prev, state];
+ // Enforce stack size limit by removing oldest entries
+ if (newStack.length > MAX_UNDO_STACK_SIZE) {
+ return newStack.slice(-MAX_UNDO_STACK_SIZE);
+ }
+ return newStack;
+ });
+ }, [textareaRef]);
+
+ /**
+ * Pop and restore the most recent undo state.
+ * Returns true if undo was performed, false if stack was empty.
+ */
+ const popUndoState = useCallback((): boolean => {
+ const textarea = textareaRef?.current;
+ if (!textarea) return false;
+
+ // We need to access current stack state for the pop operation
+ // This is handled by using a functional update and returning the result
+ let restored = false;
+ setUndoStack((prev) => {
+ if (prev.length === 0) return prev;
+
+ const newStack = prev.slice(0, -1);
+ const state = prev[prev.length - 1];
+
+ textarea.value = state.content;
+ moveCursor(textarea, state.cursorPosition);
+ onContentChange?.(state.content);
+ restored = true;
+
+ return newStack;
+ });
+
+ return restored;
+ }, [textareaRef, onContentChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -184,7 +271,15 @@ export function useViMode(options: UseViModeOptions): UseViModeResult {
setMode,
setCommandBuffer,
textarea,
- onContentChange
+ onContentChange,
+ pushUndoState,
+ popUndoState,
+ pendingOperator,
+ setPendingOperator,
+ clipboard,
+ setClipboard,
+ pendingCount,
+ setPendingCount
);
break;
@@ -197,16 +292,28 @@ export function useViMode(options: UseViModeOptions): UseViModeResult {
break;
}
},
- [enabled, mode, textareaRef, onContentChange]
+ [
+ enabled,
+ mode,
+ textareaRef,
+ onContentChange,
+ pushUndoState,
+ popUndoState,
+ pendingOperator,
+ clipboard,
+ pendingCount,
+ ]
);
return {
mode,
handleKeyDown,
commandBuffer,
- pendingCount: pendingCountRef.current,
- pendingOperator: pendingOperatorRef.current,
- clipboard: clipboardRef.current,
+ pendingCount,
+ pendingOperator,
+ clipboard,
+ undoStackSize: undoStack.length,
+ pushUndoState,
};
}
@@ -220,12 +327,94 @@ function handleNormalModeKey(
setMode: React.Dispatch>,
setCommandBuffer: React.Dispatch>,
textarea: HTMLTextAreaElement | null,
- onContentChange?: (content: string) => void
+ onContentChange?: (content: string) => void,
+ pushUndoState?: () => void,
+ popUndoState?: () => boolean,
+ pendingOperator?: "d" | "y" | null,
+ setPendingOperator?: React.Dispatch>,
+ clipboard?: string | null,
+ setClipboard?: React.Dispatch>,
+ pendingCount?: number | null,
+ setPendingCount?: React.Dispatch>
): void {
+ // Handle digit accumulation for numeric prefixes (e.g., 5j, 10dd)
+ // Digits 1-9 start a count; digit 0 extends when count pending (otherwise line start)
+ if (/[1-9]/.test(key) && pendingCount === null) {
+ e.preventDefault();
+ setPendingCount?.(parseInt(key));
+ return;
+ }
+ if (/[0-9]/.test(key) && pendingCount != null) {
+ e.preventDefault();
+ setPendingCount?.(pendingCount * 10 + parseInt(key));
+ return;
+ }
+
+ // Get the effective count for this command (default to 1)
+ const count = pendingCount ?? 1;
+
+ // Helper to clear pending count after command execution
+ const clearPendingCount = () => setPendingCount?.(null);
+ // Handle 'd' key for delete operations (dd to delete line)
+ if (key === "d" && textarea) {
+ e.preventDefault();
+ if (pendingOperator === "d") {
+ // Second 'd' pressed - execute dd (delete line) with count
+ setPendingOperator?.(null);
+ clearPendingCount();
+ executeDeleteLine(textarea, onContentChange, pushUndoState, count);
+ } else {
+ // First 'd' pressed - set pending operator
+ setPendingOperator?.("d");
+ }
+ return;
+ }
+
+ // Handle 'y' key for yank operations (yy to yank line)
+ if (key === "y" && textarea) {
+ e.preventDefault();
+ if (pendingOperator === "y") {
+ // Second 'y' pressed - execute yy (yank line) with count
+ setPendingOperator?.(null);
+ clearPendingCount();
+ executeYankLine(textarea, setClipboard, count);
+ } else {
+ // First 'y' pressed - set pending operator
+ setPendingOperator?.("y");
+ }
+ return;
+ }
+
+ // Handle 'p' key for paste after current line
+ if (key === "p" && textarea) {
+ e.preventDefault();
+ clearPendingCount();
+ executePasteAfter(textarea, clipboard, onContentChange, pushUndoState);
+ return;
+ }
+
+ // Handle 'P' key for paste before current line
+ if (key === "P" && textarea) {
+ e.preventDefault();
+ clearPendingCount();
+ executePasteBefore(textarea, clipboard, onContentChange, pushUndoState);
+ return;
+ }
+
+ // Any other key clears the pending operator (unless it's a modifier key)
+ // Note: pending count is cleared when commands execute, not here
+ if (setPendingOperator && key.length === 1 && key !== "d" && key !== "y") {
+ setPendingOperator(null);
+ }
+
// Transition to insert mode with cursor positioning
if (INSERT_MODE_KEYS.has(key)) {
e.preventDefault();
+ clearPendingCount();
if (textarea) {
+ // Snapshot state before entering insert mode.
+ // This batches all insert mode changes into a single undo entry.
+ pushUndoState?.();
executeInsertModeEntry(key, textarea, onContentChange);
}
setMode("insert");
@@ -235,6 +424,7 @@ function handleNormalModeKey(
// Transition to command mode
if (key === ":") {
e.preventDefault();
+ clearPendingCount();
setMode("command");
setCommandBuffer("");
return;
@@ -243,7 +433,24 @@ function handleNormalModeKey(
// Handle movement commands if we have a textarea
if (MOVEMENT_KEYS.has(key) && textarea) {
e.preventDefault();
- executeMovementCommand(key, textarea);
+ clearPendingCount();
+ executeMovementCommand(key, textarea, count);
+ return;
+ }
+
+ // Handle undo command
+ if (key === "u") {
+ e.preventDefault();
+ clearPendingCount();
+ popUndoState?.();
+ return;
+ }
+
+ // Handle delete character command (x)
+ if (key === "x" && textarea) {
+ e.preventDefault();
+ clearPendingCount();
+ executeDeleteCharacter(textarea, onContentChange, pushUndoState, count);
return;
}
@@ -259,78 +466,89 @@ function handleNormalModeKey(
*
* @param key - The movement key pressed
* @param textarea - The textarea element to manipulate
+ * @param count - Number of times to repeat the movement (default 1)
*/
function executeMovementCommand(
key: string,
- textarea: HTMLTextAreaElement
+ textarea: HTMLTextAreaElement,
+ count: number = 1
): void {
const text = textarea.value;
- const pos = textarea.selectionStart;
switch (key) {
case "h": {
- // Move left one character, clamp at 0
- moveCursor(textarea, pos - 1);
+ // Move left count characters, clamp at 0
+ const pos = textarea.selectionStart;
+ moveCursor(textarea, pos - count);
break;
}
case "l": {
- // Move right one character, clamp at end
- moveCursor(textarea, pos + 1);
+ // Move right count characters, clamp at end
+ const pos = textarea.selectionStart;
+ moveCursor(textarea, pos + count);
break;
}
case "j": {
- // Move down one line, trying to maintain column position
- const currentLine = getLineInfo(text, pos);
- const totalLines = getLineCount(text);
-
- // If already on the last line, stay put
- if (currentLine.lineNumber >= totalLines - 1) {
- return;
+ // Move down count lines, trying to maintain column position
+ for (let i = 0; i < count; i++) {
+ const pos = textarea.selectionStart;
+ const currentLine = getLineInfo(text, pos);
+ const totalLines = getLineCount(text);
+
+ // If already on the last line, stop
+ if (currentLine.lineNumber >= totalLines - 1) {
+ break;
+ }
+
+ const nextLinePositions = getLinePositions(
+ text,
+ currentLine.lineNumber + 1
+ );
+ if (!nextLinePositions) break;
+
+ // Try to maintain column position, but clamp to next line's length
+ const nextLineLength =
+ nextLinePositions.lineEnd - nextLinePositions.lineStart;
+ const targetColumn = Math.min(currentLine.column, nextLineLength);
+ moveCursor(textarea, nextLinePositions.lineStart + targetColumn);
}
-
- const nextLinePositions = getLinePositions(
- text,
- currentLine.lineNumber + 1
- );
- if (!nextLinePositions) return;
-
- // Try to maintain column position, but clamp to next line's length
- const nextLineLength =
- nextLinePositions.lineEnd - nextLinePositions.lineStart;
- const targetColumn = Math.min(currentLine.column, nextLineLength);
- moveCursor(textarea, nextLinePositions.lineStart + targetColumn);
break;
}
case "k": {
- // Move up one line, trying to maintain column position
- const currentLine = getLineInfo(text, pos);
+ // Move up count lines, trying to maintain column position
+ for (let i = 0; i < count; i++) {
+ const pos = textarea.selectionStart;
+ const currentLine = getLineInfo(text, pos);
- // If already on the first line, stay put
- if (currentLine.lineNumber === 0) {
- return;
+ // If already on the first line, stop
+ if (currentLine.lineNumber === 0) {
+ break;
+ }
+
+ const prevLinePositions = getLinePositions(
+ text,
+ currentLine.lineNumber - 1
+ );
+ if (!prevLinePositions) break;
+
+ // Try to maintain column position, but clamp to previous line's length
+ const prevLineLength =
+ prevLinePositions.lineEnd - prevLinePositions.lineStart;
+ const targetColumn = Math.min(currentLine.column, prevLineLength);
+ moveCursor(textarea, prevLinePositions.lineStart + targetColumn);
}
-
- const prevLinePositions = getLinePositions(
- text,
- currentLine.lineNumber - 1
- );
- if (!prevLinePositions) return;
-
- // Try to maintain column position, but clamp to previous line's length
- const prevLineLength =
- prevLinePositions.lineEnd - prevLinePositions.lineStart;
- const targetColumn = Math.min(currentLine.column, prevLineLength);
- moveCursor(textarea, prevLinePositions.lineStart + targetColumn);
break;
}
case "0": {
- // Move to start of current line
+ // Move to start of current line (count is ignored for 0)
+ const pos = textarea.selectionStart;
const currentLine = getLineInfo(text, pos);
moveCursor(textarea, currentLine.lineStart);
break;
}
case "$": {
- // Move to end of current line
+ // Move to end of current line (count is ignored for $)
+ const pos = textarea.selectionStart;
const currentLine = getLineInfo(text, pos);
moveCursor(textarea, currentLine.lineEnd);
break;
@@ -338,6 +556,259 @@ function executeMovementCommand(
}
}
+/**
+ * Execute the 'x' command: delete characters at the cursor position.
+ *
+ * In vim, 'x' deletes the character under the cursor. With a count (e.g., 5x),
+ * it deletes count characters. If the cursor is at the end of a line (past
+ * the last character) or on an empty line, nothing happens. After deletion,
+ * the cursor stays at the same position, or moves left if it would be past
+ * the end of the text.
+ *
+ * @param textarea - The textarea element to manipulate
+ * @param onContentChange - Optional callback for content changes
+ * @param pushUndoState - Optional callback to push undo state before editing
+ * @param count - Number of characters to delete (default 1)
+ */
+function executeDeleteCharacter(
+ textarea: HTMLTextAreaElement,
+ onContentChange?: (content: string) => void,
+ pushUndoState?: () => void,
+ count: number = 1
+): void {
+ const text = textarea.value;
+ const pos = textarea.selectionStart;
+
+ // Nothing to delete if at end of text or text is empty
+ if (pos >= text.length || text.length === 0) {
+ return;
+ }
+
+ // Push undo state before making changes
+ pushUndoState?.();
+
+ // Delete count characters at cursor position, clamped to available text
+ const deleteCount = Math.min(count, text.length - pos);
+ const newText = text.slice(0, pos) + text.slice(pos + deleteCount);
+ textarea.value = newText;
+
+ // Keep cursor at same position, but clamp if needed
+ moveCursor(textarea, Math.min(pos, newText.length));
+
+ // Notify parent of content change
+ onContentChange?.(newText);
+}
+
+/**
+ * Execute the 'dd' command: delete lines starting from the current line.
+ *
+ * Behavior:
+ * - Deletes count lines starting from the cursor's line, including trailing newlines
+ * - If count exceeds available lines, deletes all remaining lines
+ * - If deleting all lines, leaves an empty document
+ * - Cursor moves to the start of the next remaining line, or previous line if at end
+ *
+ * @param textarea - The textarea element to manipulate
+ * @param onContentChange - Optional callback for content changes
+ * @param pushUndoState - Optional callback to push undo state before editing
+ * @param count - Number of lines to delete (default 1)
+ */
+function executeDeleteLine(
+ textarea: HTMLTextAreaElement,
+ onContentChange?: (content: string) => void,
+ pushUndoState?: () => void,
+ count: number = 1
+): void {
+ const text = textarea.value;
+ const pos = textarea.selectionStart;
+
+ // Empty document - nothing to delete
+ if (text.length === 0) {
+ return;
+ }
+
+ // Push undo state before making changes
+ pushUndoState?.();
+
+ const lineInfo = getLineInfo(text, pos);
+ const totalLines = getLineCount(text);
+
+ // Clamp count to available lines from current position
+ const linesToDelete = Math.min(count, totalLines - lineInfo.lineNumber);
+
+ // Calculate the end line (exclusive)
+ const endLineNumber = lineInfo.lineNumber + linesToDelete;
+
+ let deleteStart: number;
+ let deleteEnd: number;
+ let newCursorPos: number;
+
+ if (linesToDelete >= totalLines) {
+ // Deleting all lines - leave empty document
+ deleteStart = 0;
+ deleteEnd = text.length;
+ newCursorPos = 0;
+ } else if (endLineNumber >= totalLines) {
+ // Deleting to end of document - need to delete preceding newline
+ deleteStart = lineInfo.lineStart - (lineInfo.lineNumber > 0 ? 1 : 0);
+ deleteEnd = text.length;
+ // Cursor goes to start of what was the previous line
+ if (lineInfo.lineNumber > 0) {
+ const prevLinePositions = getLinePositions(text, lineInfo.lineNumber - 1);
+ newCursorPos = prevLinePositions ? prevLinePositions.lineStart : 0;
+ } else {
+ newCursorPos = 0;
+ }
+ } else {
+ // Deleting from middle - include trailing newline of last deleted line
+ deleteStart = lineInfo.lineStart;
+ const lastDeletedLine = getLinePositions(text, endLineNumber - 1);
+ deleteEnd = lastDeletedLine ? lastDeletedLine.lineEnd + 1 : text.length;
+ // Cursor stays at the start of the "next" line (which moves up)
+ newCursorPos = deleteStart;
+ }
+
+ const newText = text.slice(0, deleteStart) + text.slice(deleteEnd);
+ textarea.value = newText;
+
+ // Clamp cursor position to valid range
+ moveCursor(textarea, Math.min(newCursorPos, newText.length));
+
+ // Notify parent of content change
+ onContentChange?.(newText);
+}
+
+/**
+ * Execute the 'yy' command: yank (copy) lines starting from the current line.
+ *
+ * When yanking multiple lines, they are joined with newlines. The content is
+ * stored WITHOUT a trailing newline; the newline is added during paste operations.
+ * This matches vim behavior where yanking lines and pasting creates proper new lines.
+ *
+ * @param textarea - The textarea element to read from
+ * @param setClipboard - State setter to store the yanked content
+ * @param count - Number of lines to yank (default 1)
+ */
+function executeYankLine(
+ textarea: HTMLTextAreaElement,
+ setClipboard?: React.Dispatch>,
+ count: number = 1
+): void {
+ const text = textarea.value;
+ const pos = textarea.selectionStart;
+
+ const lineInfo = getLineInfo(text, pos);
+ const totalLines = getLineCount(text);
+
+ // Clamp count to available lines from current position
+ const linesToYank = Math.min(count, totalLines - lineInfo.lineNumber);
+
+ // Collect lines
+ const lines: string[] = [];
+ for (let i = 0; i < linesToYank; i++) {
+ const linePositions = getLinePositions(text, lineInfo.lineNumber + i);
+ if (linePositions) {
+ lines.push(text.slice(linePositions.lineStart, linePositions.lineEnd));
+ }
+ }
+
+ // Join lines with newlines (vim behavior for multi-line yank)
+ const yankedContent = lines.join("\n");
+
+ // Store in clipboard
+ setClipboard?.(yankedContent);
+}
+
+/**
+ * Execute the 'p' command: paste clipboard content after the current line.
+ *
+ * Creates a new line below the current line and inserts the clipboard content.
+ * Cursor is positioned at the start of the pasted line.
+ * Does nothing if clipboard is empty.
+ *
+ * @param textarea - The textarea element to manipulate
+ * @param clipboard - The current clipboard content
+ * @param onContentChange - Optional callback for content changes
+ * @param pushUndoState - Optional callback to push undo state before editing
+ */
+function executePasteAfter(
+ textarea: HTMLTextAreaElement,
+ clipboard: string | null | undefined,
+ onContentChange?: (content: string) => void,
+ pushUndoState?: () => void
+): void {
+ // Do nothing if clipboard is empty
+ if (clipboard === null || clipboard === undefined) {
+ return;
+ }
+
+ const text = textarea.value;
+ const pos = textarea.selectionStart;
+
+ // Push undo state before making changes
+ pushUndoState?.();
+
+ const lineInfo = getLineInfo(text, pos);
+
+ // Insert newline + clipboard content after current line end
+ const insertPos = lineInfo.lineEnd;
+ const newText =
+ text.slice(0, insertPos) + "\n" + clipboard + text.slice(insertPos);
+
+ textarea.value = newText;
+
+ // Position cursor at start of pasted line (after the newline)
+ moveCursor(textarea, insertPos + 1);
+
+ // Notify parent of content change
+ onContentChange?.(newText);
+}
+
+/**
+ * Execute the 'P' command: paste clipboard content before the current line.
+ *
+ * Creates a new line above the current line and inserts the clipboard content.
+ * Cursor is positioned at the start of the pasted line.
+ * Does nothing if clipboard is empty.
+ *
+ * @param textarea - The textarea element to manipulate
+ * @param clipboard - The current clipboard content
+ * @param onContentChange - Optional callback for content changes
+ * @param pushUndoState - Optional callback to push undo state before editing
+ */
+function executePasteBefore(
+ textarea: HTMLTextAreaElement,
+ clipboard: string | null | undefined,
+ onContentChange?: (content: string) => void,
+ pushUndoState?: () => void
+): void {
+ // Do nothing if clipboard is empty
+ if (clipboard === null || clipboard === undefined) {
+ return;
+ }
+
+ const text = textarea.value;
+ const pos = textarea.selectionStart;
+
+ // Push undo state before making changes
+ pushUndoState?.();
+
+ const lineInfo = getLineInfo(text, pos);
+
+ // Insert clipboard content + newline before current line start
+ const insertPos = lineInfo.lineStart;
+ const newText =
+ text.slice(0, insertPos) + clipboard + "\n" + text.slice(insertPos);
+
+ textarea.value = newText;
+
+ // Position cursor at start of pasted line (at the insertion point)
+ moveCursor(textarea, insertPos);
+
+ // Notify parent of content change
+ onContentChange?.(newText);
+}
+
/**
* Execute an insert mode entry command, positioning the cursor appropriately.
*
From 9bf18728e672256975895ad919c87d171c7597de Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 15:04:58 -0800
Subject: [PATCH 06/18] fix(vi-mode): Remove misleading return value from
popUndoState
The return value was set inside an async state setter callback,
making it unreliable. Since the return value was never used,
removed it entirely rather than leave a trap for future code.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/hooks/useViMode.ts | 14 ++++----------
1 file changed, 4 insertions(+), 10 deletions(-)
diff --git a/frontend/src/hooks/useViMode.ts b/frontend/src/hooks/useViMode.ts
index 869ea92a..93a128b3 100644
--- a/frontend/src/hooks/useViMode.ts
+++ b/frontend/src/hooks/useViMode.ts
@@ -227,15 +227,12 @@ export function useViMode(options: UseViModeOptions): UseViModeResult {
/**
* Pop and restore the most recent undo state.
- * Returns true if undo was performed, false if stack was empty.
+ * Does nothing if stack is empty or textarea is unavailable.
*/
- const popUndoState = useCallback((): boolean => {
+ const popUndoState = useCallback((): void => {
const textarea = textareaRef?.current;
- if (!textarea) return false;
+ if (!textarea) return;
- // We need to access current stack state for the pop operation
- // This is handled by using a functional update and returning the result
- let restored = false;
setUndoStack((prev) => {
if (prev.length === 0) return prev;
@@ -245,12 +242,9 @@ export function useViMode(options: UseViModeOptions): UseViModeResult {
textarea.value = state.content;
moveCursor(textarea, state.cursorPosition);
onContentChange?.(state.content);
- restored = true;
return newStack;
});
-
- return restored;
}, [textareaRef, onContentChange]);
const handleKeyDown = useCallback(
@@ -329,7 +323,7 @@ function handleNormalModeKey(
textarea: HTMLTextAreaElement | null,
onContentChange?: (content: string) => void,
pushUndoState?: () => void,
- popUndoState?: () => boolean,
+ popUndoState?: () => void,
pendingOperator?: "d" | "y" | null,
setPendingOperator?: React.Dispatch>,
clipboard?: string | null,
From 8b9522d8d90cba7e46e2a28b1c9ee74c1bee2d72 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 15:28:05 -0800
Subject: [PATCH 07/18] feat(vi-mode): Complete vi mode with command UI, ex
commands, and integration
Chunk 12 - Command Mode UI:
- Add ViCommandLine component for ex command display
- Show ":" prefix with command text and cursor indicator
- Style to match vim command line appearance
Chunk 13 - Ex Commands:
- :w saves file (calls onSave)
- :wq/:x saves and exits (calls onSave then onExit)
- :q triggers quit with unsaved check (calls onQuitWithUnsaved)
- :q! force quits (calls onExit directly)
Chunk 14 - Integration:
- Wire useViMode, useViCursor, useHasKeyboard into PairWritingEditor
- Add ViCursor overlay, ViModeIndicator, ViCommandLine components
- Gate on vault viMode config AND keyboard detection
- Connect callbacks to existing save/exit handlers
Chunk 15 - Polish:
- Escape in Normal mode clears pending operator and count
- ViCommandLine respects isProcessingQuickAction state
- Focus stays on textarea during command mode
Completes Milestone D - vi mode feature complete.
Co-Authored-By: Claude Opus 4.5
---
.lore/work/vi-mode-pair-writing.md | 36 +-
.../pair-writing/PairWritingEditor.tsx | 125 +++-
.../pair-writing/PairWritingMode.tsx | 27 +-
.../components/pair-writing/ViCommandLine.tsx | 85 +++
.../__tests__/ViCommandLine.test.tsx | 378 ++++++++++
.../__tests__/vi-mode-integration.test.tsx | 559 +++++++++++++++
frontend/src/components/pair-writing/index.ts | 1 +
.../src/components/pair-writing/vi-mode.css | 44 ++
.../src/hooks/__tests__/useViMode.test.ts | 661 +++++++++++++++++-
frontend/src/hooks/useViMode.ts | 100 ++-
10 files changed, 1987 insertions(+), 29 deletions(-)
create mode 100644 frontend/src/components/pair-writing/ViCommandLine.tsx
create mode 100644 frontend/src/components/pair-writing/__tests__/ViCommandLine.test.tsx
create mode 100644 frontend/src/components/pair-writing/__tests__/vi-mode-integration.test.tsx
diff --git a/.lore/work/vi-mode-pair-writing.md b/.lore/work/vi-mode-pair-writing.md
index ed47f31f..acf26b24 100644
--- a/.lore/work/vi-mode-pair-writing.md
+++ b/.lore/work/vi-mode-pair-writing.md
@@ -152,10 +152,11 @@ Tasks:
- Clear count after command or Esc
- Unit tests for count accumulation and execution
-### 12. Command Mode UI
+### 12. Command Mode UI ✓
**What**: `:` command input field and parsing
**Delivers**: Can type ex commands
**Depends on**: Chunk 3 (needs Command mode state)
+**Completed**: 2026-01-29
Tasks:
- Create `ViCommandLine.tsx` component
@@ -164,10 +165,11 @@ Tasks:
- Focus management (focus input on enter, return on exit)
- Add to `vi-mode.css`
-### 13. Ex Commands
+### 13. Ex Commands ✓
**What**: `:w`, `:wq`, `:q`, `:q!` implementation
**Delivers**: Can save and exit via commands
**Depends on**: Chunk 12 (needs command input)
+**Completed**: 2026-01-29
Tasks:
- Parse command string in `useViMode`
@@ -178,10 +180,11 @@ Tasks:
- Handle unknown commands (no-op or error indicator)
- Unit tests for each command
-### 14. Integration
+### 14. Integration ✓
**What**: Wire everything into `PairWritingEditor`
**Delivers**: Full vi mode working in Pair Writing
**Depends on**: All previous chunks
+**Completed**: 2026-01-29
Tasks:
- Integrate `useViMode` into `PairWritingEditor`
@@ -191,18 +194,20 @@ Tasks:
- Gate on `viMode` config + `hasKeyboard`
- Integration tests
-### 15. Polish and Edge Cases
+### 15. Polish and Edge Cases ✓
**What**: Handle remaining edge cases from review
**Delivers**: Robust implementation
**Depends on**: Chunk 14
+**Completed**: 2026-01-29
Tasks:
-- Esc in Normal mode cancels pending operator/count
-- Cursor boundary clamping (h at start, l at end)
-- `dd` on empty document
-- `p` with empty clipboard
-- Interaction with `isProcessingQuickAction` state
-- Manual testing on various content sizes
+- Esc in Normal mode cancels pending operator/count ✓
+- Cursor boundary clamping (h at start, l at end) - verified existing tests
+- `dd` on empty document - verified existing tests
+- `p` with empty clipboard - verified existing tests
+- Interaction with `isProcessingQuickAction` state ✓
+- ViCommandLine converted to display-only (focus stays on textarea) ✓
+- Tests added for Escape clearing pending state in Normal mode
## Suggested Order
@@ -217,10 +222,10 @@ Tasks:
9. ~~**Chunk 9** (Delete) - First destructive command~~ ✓
10. ~~**Chunk 10** (Yank/Put) - Copy/paste~~ ✓
11. ~~**Chunk 11** (Numeric Prefixes) - Power user feature~~ ✓
-12. **Chunk 12** (Command Mode UI) - Needed for ex commands
-13. **Chunk 13** (Ex Commands) - Save/exit flow
-14. **Chunk 14** (Integration) - Wire it all together
-15. **Chunk 15** (Polish) - Edge cases and hardening
+12. ~~**Chunk 12** (Command Mode UI) - Needed for ex commands~~ ✓
+13. ~~**Chunk 13** (Ex Commands) - Save/exit flow~~ ✓
+14. ~~**Chunk 14** (Integration) - Wire it all together~~ ✓
+15. ~~**Chunk 15** (Polish) - Edge cases and hardening~~ ✓
## Release Points
@@ -233,4 +238,5 @@ Navigation and insert mode work. Can move around and type text.
**Milestone C (Chunks 8-11)**: ✓ COMPLETE (2026-01-29)
Full Normal mode editing. Delete, yank, put, undo, counts all work.
-**Milestone D (Chunks 12-15)**: Complete feature. Ex commands for save/exit, full integration, polished.
+**Milestone D (Chunks 12-15)**: ✓ COMPLETE (2026-01-29)
+Complete feature. Ex commands for save/exit, full integration, polished. Edge cases handled.
diff --git a/frontend/src/components/pair-writing/PairWritingEditor.tsx b/frontend/src/components/pair-writing/PairWritingEditor.tsx
index d7d2777d..cbdfb27e 100644
--- a/frontend/src/components/pair-writing/PairWritingEditor.tsx
+++ b/frontend/src/components/pair-writing/PairWritingEditor.tsx
@@ -6,6 +6,12 @@
*
* Unlike MemoryEditor, this component receives content via props rather than
* fetching a specific file, making it suitable for editing any vault file.
+ *
+ * Optionally supports vi mode editing (when viModeEnabled prop is true and
+ * a physical keyboard is detected). Vi mode provides vim-style navigation
+ * and commands for power users.
+ *
+ * @see .lore/specs/vi-mode-pair-writing.md
*/
import { useState, useCallback, useEffect, useRef } from "react";
@@ -25,7 +31,14 @@ import {
useTextSelection,
type SelectionContext,
} from "../../hooks/useTextSelection";
+import { useHasKeyboard } from "../../hooks/useHasKeyboard";
+import { useViMode } from "../../hooks/useViMode";
+import { useViCursor } from "../../hooks/useViCursor";
+import { ViCursor } from "./ViCursor";
+import { ViModeIndicator } from "./ViModeIndicator";
+import { ViCommandLine } from "./ViCommandLine";
import "./PairWritingEditor.css";
+import "./vi-mode.css";
export interface PairWritingEditorProps {
initialContent: string;
@@ -50,6 +63,14 @@ export interface PairWritingEditorProps {
ContextMenuComponent?: typeof EditorContextMenu;
/** Increment this to trigger opening the context menu (for toolbar Actions button) */
openMenuTrigger?: number;
+ /** Whether vi mode is enabled for this vault (from vault config) */
+ viModeEnabled?: boolean;
+ /** Called when vi mode :w or :wq command is executed */
+ onSave?: () => void;
+ /** Called when vi mode :q! or :wq command is executed */
+ onExit?: () => void;
+ /** Called when vi mode :q command is executed with unsaved changes */
+ onQuitWithUnsaved?: () => void;
}
export function PairWritingEditor({
@@ -65,6 +86,10 @@ export function PairWritingEditor({
hasSnapshot = false,
ContextMenuComponent = EditorContextMenu,
openMenuTrigger = 0,
+ viModeEnabled = false,
+ onSave,
+ onExit,
+ onQuitWithUnsaved,
}: PairWritingEditorProps): React.ReactNode {
const [content, setContent] = useState(initialContent);
const [menuOpen, setMenuOpen] = useState(false);
@@ -76,6 +101,52 @@ export function PairWritingEditor({
const { selection } = useTextSelection(textareaRef, content);
+ // Vi mode integration
+ // Only enable vi mode if the vault has it enabled AND a keyboard is detected
+ const hasKeyboard = useHasKeyboard();
+ const viEnabled = viModeEnabled && hasKeyboard;
+
+ // Track cursor position for vi mode overlay
+ const [cursorPosition, setCursorPosition] = useState(0);
+
+ // Handle content changes from vi mode commands
+ const handleViContentChange = useCallback(
+ (newContent: string) => {
+ setContent(newContent);
+ onContentChange?.(newContent);
+ },
+ [onContentChange]
+ );
+
+ // Vi mode hook provides mode state, key handler, and command buffer
+ const {
+ mode: viMode,
+ handleKeyDown: viHandleKeyDown,
+ commandBuffer,
+ } = useViMode({
+ enabled: viEnabled,
+ textareaRef,
+ onSave,
+ onExit,
+ onQuitWithUnsaved,
+ onContentChange: handleViContentChange,
+ });
+
+ // Vi cursor hook provides cursor overlay positioning
+ const { cursorStyle, showOverlay } = useViCursor({
+ textareaRef,
+ cursorPosition,
+ mode: viMode,
+ enabled: viEnabled,
+ });
+
+ // Update cursor position when textarea selection changes
+ const handleSelect = useCallback(() => {
+ if (textareaRef.current) {
+ setCursorPosition(textareaRef.current.selectionStart);
+ }
+ }, []);
+
// Notify parent when selection changes
useEffect(() => {
onSelectionChange?.(selection);
@@ -135,10 +206,28 @@ export function PairWritingEditor({
const newContent = e.target.value;
setContent(newContent);
onContentChange?.(newContent);
+ // Update cursor position for vi mode overlay
+ setCursorPosition(e.target.selectionStart);
},
[onContentChange]
);
+ // Handle keydown: pass through vi mode handler when enabled
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ viHandleKeyDown(e);
+ // Update cursor position after key processing
+ // Use requestAnimationFrame to ensure we get the updated position
+ // after the key event is processed
+ requestAnimationFrame(() => {
+ if (textareaRef.current) {
+ setCursorPosition(textareaRef.current.selectionStart);
+ }
+ });
+ },
+ [viHandleKeyDown]
+ );
+
const openContextMenu = useCallback((position: MenuPosition) => {
if (!selectionRef.current) return;
setMenuPosition(position);
@@ -204,19 +293,53 @@ export function PairWritingEditor({
const processingClass = isProcessingQuickAction ? " pair-writing-editor--processing" : "";
const textareaProcessingClass = isProcessingQuickAction ? " pair-writing-editor__textarea--processing" : "";
+ // Vi mode adds classes to hide native caret when in normal/command mode
+ const viModeClass = viEnabled && viMode === "normal" ? " pair-writing-editor__textarea--vi-normal" : "";
+ const viCommandClass = viEnabled && viMode === "command" ? " pair-writing-editor__textarea--vi-command" : "";
+
return (
+ {/* Vi mode cursor overlay - shown in normal/command mode */}
+
+
+ {/* Vi mode indicator - shows current mode at bottom of editor */}
+
+
+ {/* Vi command line - shown when in command mode for ex commands */}
+ {/* Hidden during quick action processing to prevent interference */}
+ {
+ // Command execution is handled by useViMode internally
+ // The onSubmit here is called by the command line when Enter is pressed
+ // but useViMode's handleKeyDown already handles this
+ }}
+ onCancel={() => {
+ // Cancellation is handled by useViMode internally via Escape key
+ }}
+ onChange={() => {
+ // Value changes are handled by useViMode internally
+ }}
+ />
+
{isProcessingQuickAction && (
diff --git a/frontend/src/components/pair-writing/ViCommandLine.tsx b/frontend/src/components/pair-writing/ViCommandLine.tsx
new file mode 100644
index 00000000..621e5f32
--- /dev/null
+++ b/frontend/src/components/pair-writing/ViCommandLine.tsx
@@ -0,0 +1,85 @@
+/**
+ * ViCommandLine Component - Ex Command Display for Vi Mode
+ *
+ * Renders a vim-style command line display at the bottom of the editor.
+ * Appears when user presses ':' in Normal mode. Shows a ":" prefix
+ * followed by the current command buffer text.
+ *
+ * This is a pure display component: all keyboard input is handled by
+ * useViMode through the textarea's keydown events. The textarea retains
+ * focus while in command mode; this component just displays what's being
+ * typed.
+ *
+ * @see .lore/specs/vi-mode-pair-writing.md (REQ-14, REQ-19)
+ * @see .lore/plans/vi-mode-pair-writing.md (TD-5)
+ */
+
+import "./vi-mode.css";
+
+export interface ViCommandLineProps {
+ /** Whether the command line should be visible */
+ visible: boolean;
+ /** Current command buffer value (without the leading colon) */
+ value: string;
+ /** Called when user submits the command (presses Enter) - handled by useViMode */
+ onSubmit: (command: string) => void;
+ /** Called when user cancels (presses Escape) - handled by useViMode */
+ onCancel: () => void;
+ /** Called when value changes (user types) - handled by useViMode */
+ onChange: (value: string) => void;
+}
+
+/**
+ * Vim-style command line display.
+ *
+ * This component renders at the bottom of the editor container, showing
+ * a ":" prefix followed by the command buffer text. The textarea retains
+ * focus; this is a pure display component.
+ *
+ * Key events are handled by useViMode through the textarea's onKeyDown,
+ * which updates the command buffer. The props onSubmit, onCancel, and
+ * onChange are kept for API compatibility but are not used (the actual
+ * handling happens in useViMode's handleCommandModeKey).
+ *
+ * @example
+ * ```tsx
+ * const { mode, commandBuffer } = useViMode({...});
+ *
+ * return (
+ *
+ Configure the Pair Writing editing experience.
+
+
+
+
+
+ Use vi-style modal editing in Pair Writing. Requires a physical keyboard.
+
+
+
{/* Footer with actions */}
From 25f6684880a4ccdacb9cb026d4ed9a31aa114008 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 19:25:21 -0800
Subject: [PATCH 10/18] fix(vi-mode): Include viMode in config editor
initialConfig
The viMode field was missing from the initialConfig passed to
ConfigEditorDialog in both App.tsx and VaultSelect.tsx, causing
the value to not be saved when toggling the checkbox.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/App.tsx | 1 +
frontend/src/components/vault/VaultSelect.tsx | 1 +
2 files changed, 2 insertions(+)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a40ddf9f..b5209aa8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -261,6 +261,7 @@ function MainContent(): React.ReactNode {
badges: vault.badges,
order: vault.order === Infinity ? undefined : vault.order,
cardsEnabled: vault.cardsEnabled,
+ viMode: vault.viMode,
}}
onSave={handleConfigSave}
onCancel={handleConfigCancel}
diff --git a/frontend/src/components/vault/VaultSelect.tsx b/frontend/src/components/vault/VaultSelect.tsx
index 0dfe53e6..21e1d298 100644
--- a/frontend/src/components/vault/VaultSelect.tsx
+++ b/frontend/src/components/vault/VaultSelect.tsx
@@ -722,6 +722,7 @@ export function VaultSelect({ onReady }: VaultSelectProps): React.ReactNode {
badges: configEditorVault.badges,
order: configEditorVault.order === Infinity ? undefined : configEditorVault.order,
cardsEnabled: configEditorVault.cardsEnabled,
+ viMode: configEditorVault.viMode,
}}
onSave={handleConfigSave}
onCancel={handleConfigCancel}
From ef98b12669eee2fde83666b53b7db89fb59855f9 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 19:48:15 -0800
Subject: [PATCH 11/18] fix(vi-mode): Update local state after config save
The viMode field was missing from local state updates after saving:
- VaultSelect.tsx: setVaults() wasn't updating viMode
- reducer.ts: UPDATE_VAULT_CONFIG wasn't applying viMode
This caused the checkbox to appear stuck after toggling because
the local state wasn't updated, so reopening the dialog showed
the old value.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/components/vault/VaultSelect.tsx | 1 +
frontend/src/contexts/session/reducer.ts | 1 +
2 files changed, 2 insertions(+)
diff --git a/frontend/src/components/vault/VaultSelect.tsx b/frontend/src/components/vault/VaultSelect.tsx
index 21e1d298..d9515dfb 100644
--- a/frontend/src/components/vault/VaultSelect.tsx
+++ b/frontend/src/components/vault/VaultSelect.tsx
@@ -376,6 +376,7 @@ export function VaultSelect({ onReady }: VaultSelectProps): React.ReactNode {
badges: config.badges ?? v.badges,
order: config.order ?? v.order,
cardsEnabled: config.cardsEnabled ?? v.cardsEnabled,
+ viMode: config.viMode ?? v.viMode,
}
: v
)
diff --git a/frontend/src/contexts/session/reducer.ts b/frontend/src/contexts/session/reducer.ts
index 8e609c50..7ede144b 100644
--- a/frontend/src/contexts/session/reducer.ts
+++ b/frontend/src/contexts/session/reducer.ts
@@ -728,6 +728,7 @@ export function sessionReducer(
badges: action.config.badges ?? state.vault.badges,
order: action.config.order ?? state.vault.order,
cardsEnabled: action.config.cardsEnabled ?? state.vault.cardsEnabled,
+ viMode: action.config.viMode ?? state.vault.viMode,
},
};
From c62f48e8a666c3dcd79cd35fdec9d3ed4711c75f Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 19:55:27 -0800
Subject: [PATCH 12/18] fix: Make vi mode block cursor visible in normal mode
Changed cursor background from --color-text (same as text color)
to --color-accent-quartary (Sunset Gold) so it's visible against
both the dark background and text. Also softened the blink animation
to fade between 0.85-0.3 opacity instead of completely disappearing.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/components/pair-writing/vi-mode.css | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/pair-writing/vi-mode.css b/frontend/src/components/pair-writing/vi-mode.css
index 72cede70..bfa70159 100644
--- a/frontend/src/components/pair-writing/vi-mode.css
+++ b/frontend/src/components/pair-writing/vi-mode.css
@@ -21,8 +21,8 @@
.vi-cursor {
position: absolute;
pointer-events: none;
- background-color: var(--color-text, #e0e0e0);
- opacity: 0.7;
+ background-color: var(--color-accent-quartary, #d4a574);
+ opacity: 0.85;
width: 0.6em;
animation: vi-cursor-blink 1s step-end infinite;
z-index: 5;
@@ -32,17 +32,17 @@
@keyframes vi-cursor-blink {
0%,
100% {
- opacity: 0.7;
+ opacity: 0.85;
}
50% {
- opacity: 0;
+ opacity: 0.3;
}
}
/* Disable blink when textarea is not focused */
.pair-writing-editor:not(:focus-within) .vi-cursor {
animation: none;
- opacity: 0.4;
+ opacity: 0.5;
}
/* ============================================
From 34ea8287819ad8021812a988a99b0fb3d8d4750b Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 20:01:08 -0800
Subject: [PATCH 13/18] fix: Calculate vi cursor position relative to mirror
element
The cursor position was being calculated by subtracting textarea's
viewport position from the span's viewport position, but the mirror
element is positioned at -9999px off-screen. This resulted in very
negative coordinates.
Fixed by measuring the span position relative to the mirror element
instead, which gives correct offsets that work as the cursor position.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/hooks/useViCursor.ts | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/frontend/src/hooks/useViCursor.ts b/frontend/src/hooks/useViCursor.ts
index b08c79d2..33d0fe0a 100644
--- a/frontend/src/hooks/useViCursor.ts
+++ b/frontend/src/hooks/useViCursor.ts
@@ -108,8 +108,8 @@ function copyTextareaStyles(
* 1. Create off-screen div with identical styling to textarea
* 2. Split content at cursor position
* 3. Insert span marker between text nodes
- * 4. Measure span's getBoundingClientRect() for coordinates
- * 5. Adjust for textarea position and scroll offset
+ * 4. Measure span's position relative to the mirror
+ * 5. Account for textarea scroll offset
*/
export function calculateCursorPosition(
textarea: HTMLTextAreaElement,
@@ -144,13 +144,14 @@ export function calculateCursorPosition(
}
mirror.appendChild(cursorSpan);
- // Get textarea's position
- const textareaRect = textarea.getBoundingClientRect();
+ // Get positions - measure span relative to mirror, not viewport
+ const mirrorRect = mirror.getBoundingClientRect();
const spanRect = cursorSpan.getBoundingClientRect();
- // Calculate position relative to textarea, accounting for scroll
- const top = spanRect.top - textareaRect.top + textarea.scrollTop;
- const left = spanRect.left - textareaRect.left + textarea.scrollLeft;
+ // Calculate position relative to mirror (which has same styling as textarea)
+ // Then account for scroll offset
+ const top = spanRect.top - mirrorRect.top - textarea.scrollTop;
+ const left = spanRect.left - mirrorRect.left - textarea.scrollLeft;
// Get line height from computed style for cursor height
const computed = window.getComputedStyle(textarea);
From 8c71a3ef08be012c199dbd4cd72654ab8cbd996f Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 20:05:46 -0800
Subject: [PATCH 14/18] feat: Auto-scroll textarea when vi cursor moves
off-screen
Added scrollCursorIntoView() that calculates cursor line position and
adjusts textarea.scrollTop when the cursor moves outside the visible
area. Called after j/k movement commands to keep cursor visible.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/hooks/useViMode.ts | 42 +++++++++++++++++++++++++++++++++
1 file changed, 42 insertions(+)
diff --git a/frontend/src/hooks/useViMode.ts b/frontend/src/hooks/useViMode.ts
index 8b4393be..75e1fb96 100644
--- a/frontend/src/hooks/useViMode.ts
+++ b/frontend/src/hooks/useViMode.ts
@@ -178,6 +178,44 @@ export function moveCursor(
textarea.selectionEnd = clamped;
}
+/**
+ * Scroll the textarea to ensure the cursor is visible.
+ *
+ * Uses a mirror element technique similar to cursor positioning to calculate
+ * where the cursor line is, then adjusts scrollTop if needed.
+ *
+ * @param textarea - The textarea element
+ */
+export function scrollCursorIntoView(textarea: HTMLTextAreaElement): void {
+ const pos = textarea.selectionStart;
+ const text = textarea.value;
+
+ // Get line info to find which line we're on
+ const lineInfo = getLineInfo(text, pos);
+
+ // Get computed line height
+ const computed = window.getComputedStyle(textarea);
+ const lineHeight = parseFloat(computed.lineHeight) || 20;
+ const paddingTop = parseFloat(computed.paddingTop) || 0;
+
+ // Calculate the top position of the cursor line
+ const cursorTop = paddingTop + lineInfo.lineNumber * lineHeight;
+ const cursorBottom = cursorTop + lineHeight;
+
+ // Get the visible area
+ const visibleTop = textarea.scrollTop;
+ const visibleBottom = visibleTop + textarea.clientHeight;
+
+ // Scroll if cursor is outside visible area
+ if (cursorTop < visibleTop) {
+ // Cursor is above visible area - scroll up
+ textarea.scrollTop = cursorTop;
+ } else if (cursorBottom > visibleBottom) {
+ // Cursor is below visible area - scroll down
+ textarea.scrollTop = cursorBottom - textarea.clientHeight;
+ }
+}
+
export function useViMode(options: UseViModeOptions): UseViModeResult {
const { enabled, textareaRef, onContentChange, onSave, onExit, onQuitWithUnsaved } = options;
@@ -524,6 +562,8 @@ function executeMovementCommand(
const targetColumn = Math.min(currentLine.column, nextLineLength);
moveCursor(textarea, nextLinePositions.lineStart + targetColumn);
}
+ // Ensure cursor stays visible after vertical movement
+ scrollCursorIntoView(textarea);
break;
}
case "k": {
@@ -549,6 +589,8 @@ function executeMovementCommand(
const targetColumn = Math.min(currentLine.column, prevLineLength);
moveCursor(textarea, prevLinePositions.lineStart + targetColumn);
}
+ // Ensure cursor stays visible after vertical movement
+ scrollCursorIntoView(textarea);
break;
}
case "0": {
From 94ec983f910231e62c782be3216b835c771ea4b0 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 20:09:05 -0800
Subject: [PATCH 15/18] fix: Add scroll margin when moving cursor up
The up-scroll was triggering too late (5-6 lines off-screen).
Added a 3-line margin so scrolling starts before the cursor
reaches the top edge, matching the responsive feel of down-scrolling.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/hooks/useViMode.ts | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/frontend/src/hooks/useViMode.ts b/frontend/src/hooks/useViMode.ts
index 75e1fb96..cf9806e8 100644
--- a/frontend/src/hooks/useViMode.ts
+++ b/frontend/src/hooks/useViMode.ts
@@ -181,8 +181,9 @@ export function moveCursor(
/**
* Scroll the textarea to ensure the cursor is visible.
*
- * Uses a mirror element technique similar to cursor positioning to calculate
- * where the cursor line is, then adjusts scrollTop if needed.
+ * Uses line-based calculation to determine cursor position, then adjusts
+ * scrollTop if needed. Includes a margin so scrolling starts before the
+ * cursor actually goes off-screen.
*
* @param textarea - The textarea element
*/
@@ -206,10 +207,13 @@ export function scrollCursorIntoView(textarea: HTMLTextAreaElement): void {
const visibleTop = textarea.scrollTop;
const visibleBottom = visibleTop + textarea.clientHeight;
- // Scroll if cursor is outside visible area
- if (cursorTop < visibleTop) {
- // Cursor is above visible area - scroll up
- textarea.scrollTop = cursorTop;
+ // Margin to start scrolling before cursor goes off-screen
+ const scrollMargin = lineHeight * 3;
+
+ // Scroll if cursor is approaching edge of visible area
+ if (cursorTop < visibleTop + scrollMargin) {
+ // Cursor is approaching top - scroll to keep margin above cursor
+ textarea.scrollTop = Math.max(0, cursorTop - scrollMargin);
} else if (cursorBottom > visibleBottom) {
// Cursor is below visible area - scroll down
textarea.scrollTop = cursorBottom - textarea.clientHeight;
From 63c7426bb007d21734810cf9733203f0b1540957 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 20:13:00 -0800
Subject: [PATCH 16/18] fix: Use mirror element for scroll calculation with
wrapped lines
The previous line-based calculation assumed one logical line = one
visual line, which broke with text wrapping. Now uses the same mirror
element technique as cursor positioning to get the actual pixel
position accounting for wrapped text.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/hooks/useViMode.ts | 91 +++++++++++++++++++++++----------
1 file changed, 64 insertions(+), 27 deletions(-)
diff --git a/frontend/src/hooks/useViMode.ts b/frontend/src/hooks/useViMode.ts
index cf9806e8..fc973ecf 100644
--- a/frontend/src/hooks/useViMode.ts
+++ b/frontend/src/hooks/useViMode.ts
@@ -181,9 +181,9 @@ export function moveCursor(
/**
* Scroll the textarea to ensure the cursor is visible.
*
- * Uses line-based calculation to determine cursor position, then adjusts
- * scrollTop if needed. Includes a margin so scrolling starts before the
- * cursor actually goes off-screen.
+ * Uses a mirror element technique to calculate the actual pixel position
+ * of the cursor, accounting for text wrapping. Then adjusts scrollTop
+ * if the cursor is outside the visible area.
*
* @param textarea - The textarea element
*/
@@ -191,32 +191,69 @@ export function scrollCursorIntoView(textarea: HTMLTextAreaElement): void {
const pos = textarea.selectionStart;
const text = textarea.value;
- // Get line info to find which line we're on
- const lineInfo = getLineInfo(text, pos);
-
- // Get computed line height
+ // Get computed styles
const computed = window.getComputedStyle(textarea);
const lineHeight = parseFloat(computed.lineHeight) || 20;
- const paddingTop = parseFloat(computed.paddingTop) || 0;
-
- // Calculate the top position of the cursor line
- const cursorTop = paddingTop + lineInfo.lineNumber * lineHeight;
- const cursorBottom = cursorTop + lineHeight;
-
- // Get the visible area
- const visibleTop = textarea.scrollTop;
- const visibleBottom = visibleTop + textarea.clientHeight;
-
- // Margin to start scrolling before cursor goes off-screen
- const scrollMargin = lineHeight * 3;
-
- // Scroll if cursor is approaching edge of visible area
- if (cursorTop < visibleTop + scrollMargin) {
- // Cursor is approaching top - scroll to keep margin above cursor
- textarea.scrollTop = Math.max(0, cursorTop - scrollMargin);
- } else if (cursorBottom > visibleBottom) {
- // Cursor is below visible area - scroll down
- textarea.scrollTop = cursorBottom - textarea.clientHeight;
+
+ // Create mirror element to measure actual cursor position with wrapping
+ const mirror = document.createElement("div");
+ mirror.style.position = "absolute";
+ mirror.style.visibility = "hidden";
+ mirror.style.whiteSpace = "pre-wrap";
+ mirror.style.wordWrap = "break-word";
+ mirror.style.overflow = "hidden";
+ mirror.style.left = "-9999px";
+ mirror.style.top = "-9999px";
+
+ // Copy relevant styles from textarea
+ const stylesToCopy = [
+ "font-family", "font-size", "font-weight", "line-height",
+ "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
+ "border-width", "box-sizing", "width", "letter-spacing", "word-spacing"
+ ];
+ for (const prop of stylesToCopy) {
+ mirror.style.setProperty(prop, computed.getPropertyValue(prop));
+ }
+
+ document.body.appendChild(mirror);
+
+ try {
+ // Insert text up to cursor with a marker span
+ const textBefore = text.substring(0, pos);
+ const cursorSpan = document.createElement("span");
+ cursorSpan.textContent = "\u00A0"; // Non-breaking space as marker
+
+ mirror.textContent = "";
+ if (textBefore) {
+ mirror.appendChild(document.createTextNode(textBefore));
+ }
+ mirror.appendChild(cursorSpan);
+
+ // Measure position relative to mirror
+ const mirrorRect = mirror.getBoundingClientRect();
+ const spanRect = cursorSpan.getBoundingClientRect();
+
+ // Cursor position relative to textarea content (not viewport)
+ const cursorTop = spanRect.top - mirrorRect.top;
+ const cursorBottom = cursorTop + lineHeight;
+
+ // Get the visible area
+ const visibleTop = textarea.scrollTop;
+ const visibleBottom = visibleTop + textarea.clientHeight;
+
+ // Margin to start scrolling before cursor goes off-screen
+ const scrollMargin = lineHeight * 2;
+
+ // Scroll if cursor is approaching edge of visible area
+ if (cursorTop < visibleTop + scrollMargin) {
+ // Cursor is approaching top - scroll to keep margin above cursor
+ textarea.scrollTop = Math.max(0, cursorTop - scrollMargin);
+ } else if (cursorBottom > visibleBottom - scrollMargin) {
+ // Cursor is approaching bottom - scroll to keep margin below cursor
+ textarea.scrollTop = cursorBottom - textarea.clientHeight + scrollMargin;
+ }
+ } finally {
+ document.body.removeChild(mirror);
}
}
From 5a29100c0aa6dd7895c01b53e5b776abc15df964 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 20:17:09 -0800
Subject: [PATCH 17/18] feat: Auto-focus textarea when Pair Writing mode opens
Focus the textarea on mount so users can start typing immediately
without clicking the text area first.
Co-Authored-By: Claude Opus 4.5
---
frontend/src/components/pair-writing/PairWritingEditor.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/frontend/src/components/pair-writing/PairWritingEditor.tsx b/frontend/src/components/pair-writing/PairWritingEditor.tsx
index 02aae46e..6999451e 100644
--- a/frontend/src/components/pair-writing/PairWritingEditor.tsx
+++ b/frontend/src/components/pair-writing/PairWritingEditor.tsx
@@ -99,6 +99,11 @@ export function PairWritingEditor({
const textareaRef = useRef(null);
+ // Auto-focus textarea on mount so user can start typing immediately
+ useEffect(() => {
+ textareaRef.current?.focus();
+ }, []);
+
const { selection } = useTextSelection(textareaRef, content);
// Vi mode integration
From e9cb9f04dbf45c8b0e5a1f317280f2c0b79c3dd8 Mon Sep 17 00:00:00 2001
From: Ronald Roy
Date: Thu, 29 Jan 2026 20:24:27 -0800
Subject: [PATCH 18/18] docs: Add retro for vi mode implementation
Captures lessons learned from the vi mode feature:
- Visual components need visual testing (cursor was invisible)
- Config changes must be traced end-to-end
- Text wrapping breaks line-based calculations
- Polish emerges from actual use
Co-Authored-By: Claude Opus 4.5
---
.lore/retros/vi-mode-pair-writing.md | 48 ++++++++++++++++++++++++++++
1 file changed, 48 insertions(+)
create mode 100644 .lore/retros/vi-mode-pair-writing.md
diff --git a/.lore/retros/vi-mode-pair-writing.md b/.lore/retros/vi-mode-pair-writing.md
new file mode 100644
index 00000000..d7e3322c
--- /dev/null
+++ b/.lore/retros/vi-mode-pair-writing.md
@@ -0,0 +1,48 @@
+# Retro: Vi Mode for Pair Writing
+
+## Summary
+
+Added vi-style modal editing to Pair Writing mode. Implementation includes Normal/Insert/Command modes, hjkl navigation, line operations (dd, yy, p, P), numeric prefixes, internal undo stack, ex commands (:w, :wq, :q, :q!), block cursor overlay, mode indicator, and auto-scrolling. Feature is gated on vault config toggle + keyboard detection.
+
+## What Went Well
+
+- **Detailed upfront planning paid off**: The 15-chunk breakdown with clear dependencies allowed systematic implementation without backtracking. Each chunk built cleanly on the previous.
+
+- **Hook-based architecture**: Isolating vi logic in `useViMode` and cursor logic in `useViCursor` made unit testing straightforward. 256 tests for the core hook alone.
+
+- **Spec constraints were realistic**: Explicitly scoping out Visual mode, word motions, and search kept the feature focused. No scope creep during implementation.
+
+- **The research phase identified the right approach**: Mirror element technique for cursor positioning was researched beforehand and worked well once correctly implemented.
+
+- **Incremental milestones**: Being able to see mode indicator and block cursor (Milestone A) before any commands worked provided early visual feedback.
+
+## What Could Improve
+
+- **Cursor overlay had multiple bugs**: The mirror element calculation had a fundamental error (measuring viewport-relative instead of mirror-relative), and the color choice made it invisible. Should have tested visually earlier in the process.
+
+- **Config state sync was incomplete**: Adding `viMode` to the schema wasn't enough - had to trace through multiple frontend state sync points (initialConfig in two places, reducer UPDATE_VAULT_CONFIG, VaultSelect setVaults). The data flow for config changes is more complex than the plan acknowledged.
+
+- **Scroll behavior with wrapped lines**: Line-based scroll calculation assumed 1 logical line = 1 visual line. Real documents have wrapped lines. Should have considered this in the plan.
+
+- **Testing in JSDOM vs real browser**: Some issues (cursor visibility, scroll behavior) only surfaced during manual testing. JSDOM tests passed but didn't catch visual/layout bugs.
+
+## Lessons Learned
+
+1. **Visual components need visual testing**: Unit tests for cursor position calculation passed, but the actual cursor was invisible. For overlay/positioning code, add a manual test checkpoint before declaring the chunk complete.
+
+2. **Trace config changes end-to-end**: When adding a new config field, grep for all places the config object is constructed, copied, or merged. In this codebase: shared schema, backend config loading, frontend initialConfig props (multiple components), reducer cases, and post-save state updates.
+
+3. **Text wrapping breaks line math**: Any calculation involving "line N is at position Y pixels" needs to account for soft wrapping. Use the same measurement technique (mirror element) for both cursor rendering and scroll calculations.
+
+4. **Polish emerges from use**: Auto-focus and auto-scroll weren't in the original spec but became obvious needs during real usage. Budget time for this "last 10%" polish.
+
+5. **Color visibility depends on context**: A color that's visible in isolation may not be visible when overlaid on similar colors. The cursor used `--color-text` which matched the text itself. Use accent colors for overlays.
+
+## Artifacts
+
+- Spec: `.lore/specs/vi-mode-pair-writing.md`
+- Plan: `.lore/plans/vi-mode-pair-writing.md`
+- Work breakdown: `.lore/work/vi-mode-pair-writing.md`
+- Research: `.lore/research/vi-mode-implementation.md`
+- Issue: #394
+- PR: #433