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/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 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..acf26b24 --- /dev/null +++ b/.lore/work/vi-mode-pair-writing.md @@ -0,0 +1,242 @@ +# 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 +**Completed**: 2026-01-29 + +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) +**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) ✓ +**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 +- 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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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) +**Completed**: 2026-01-29 + +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 +**Completed**: 2026-01-29 + +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 +**Completed**: 2026-01-29 + +Tasks: +- 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 + +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)**: ✓ 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)**: ✓ COMPLETE (2026-01-29) +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 (2026-01-29) +Complete feature. Ex commands for save/exit, full integration, polished. Edge cases handled. 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/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/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/PairWritingEditor.tsx b/frontend/src/components/pair-writing/PairWritingEditor.tsx index d7d2777d..6999451e 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); @@ -74,8 +99,59 @@ 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 + // 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 +211,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 +298,42 @@ 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 (