From 9b2eb36c92ec606e0920e26b0a9b523e9fa8cc43 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Tue, 14 Jul 2026 05:26:51 +0200 Subject: [PATCH] feat(tui): disable vim mode and remove dead code The vim mode implementation was structurally broken: the editor bridge that connects vim motions to the buffer was never wired, so every editing operation silently no-op'd while the mode indicator claimed vim was active. Keys were consumed without editing, leaving users with a non-functional editor. Disable vim honestly instead of leaving the broken experience: /vim now reports that the feature is unavailable, the dead handler and tests are removed (730 LOC), all vim wiring is stripped from the TUI, the config flag is deprecated, and docs are updated. Emacs/readline-style keybindings remain available. --- README.md | 19 +- docs/platforms/tui-redesign.md | 2 +- src/config/types.mayros.ts | 2 +- src/tui/commands.ts | 2 +- src/tui/components/custom-editor.ts | 12 - src/tui/keybinding-resolver.test.ts | 5 +- src/tui/keybinding-resolver.ts | 4 +- src/tui/tui-command-handlers.ts | 4 +- src/tui/tui.ts | 16 +- src/tui/vim-handler.test.ts | 302 -------------------- src/tui/vim-handler.ts | 428 ---------------------------- 11 files changed, 17 insertions(+), 779 deletions(-) delete mode 100644 src/tui/vim-handler.test.ts delete mode 100644 src/tui/vim-handler.ts diff --git a/README.md b/README.md index a1f15900..360b393a 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,6 @@ mayros -p "refactor auth flow" # headless mode (non-interactive) - 🎨 3 themes (dark, light, high-contrast) β€” `/theme` - πŸ“ 3 output styles (standard, explanatory, learning) β€” `/style` -- ⌨️ Vim mode with motions, operators, undo β€” `/vim` - πŸ“‹ `Ctrl+V` image paste from clipboard - πŸ“Š `/diff` inline diff viewer Β· `/context` token usage chart - πŸ—ΊοΈ `/plan` semantic plan mode (Cortex-backed) @@ -165,15 +164,15 @@ mayros -p "refactor auth flow" # headless mode (non-interactive) **Slash commands (30+):** -| Command | Description | Command | Description | -| ---------------- | ----------------- | ---------------- | -------------------- | -| `/help` | List all commands | `/plan` | Semantic plan mode | -| `/new` | Reset session | `/diff` | Show pending changes | -| `/compact` | Compact context | `/context` | Token usage chart | -| `/think ` | Set thinking | `/theme` | Cycle themes | -| `/model ` | Switch model | `/vim` | Toggle vim mode | -| `/permission` | Permission mode | `/copy` | Copy last response | -| `/fast` | Fast mode | `/export [file]` | Export session | +| Command | Description | Command | Description | +| ---------------- | ----------------- | ------------- | -------------------- | +| `/help` | List all commands | `/plan` | Semantic plan mode | +| `/new` | Reset session | `/diff` | Show pending changes | +| `/compact` | Compact context | `/context` | Token usage chart | +| `/think ` | Set thinking | `/theme` | Cycle themes | +| `/model ` | Switch model | `/permission` | Permission mode | +| `/fast` | Fast mode | `/copy` | Copy last response | +| `/export [file]` | Export session | `/context` | Token usage chart | **Markdown-driven extensibility:** diff --git a/docs/platforms/tui-redesign.md b/docs/platforms/tui-redesign.md index edc609c2..e98a9834 100644 --- a/docs/platforms/tui-redesign.md +++ b/docs/platforms/tui-redesign.md @@ -81,7 +81,7 @@ Priorities are assigned as P0 (correctness or blocker), P1 (high-value polish), ### P0 β€” Correctness and blockers -1. **Verify the vim bridge is connected.** If the editor bridge is never set from the TUI side, every vim motion that relies on it silently no-ops. Confirm or fix before any further vim work. +1. ~~**Verify the vim bridge is connected.**~~ **Resolved: vim mode has been disabled.** The bridge was never wired and the rendering dependency does not expose the cursor API needed to fix it cleanly. The `/vim` command now reports that the feature is unavailable, the dead handler and its tests have been removed, and the config flag is deprecated. Emacs/readline-style keybindings (Ctrl+A/E/W/U/K) remain available via the editor. 2. **Accessibility TUI feature parity.** Either linearize the graphical renderer for screen readers or bring slash commands, session switching, and abort into the accessibility TUI. The current split excludes users from most functionality. ### P1 β€” High-value polish diff --git a/src/config/types.mayros.ts b/src/config/types.mayros.ts index ec33a448..b77abeab 100644 --- a/src/config/types.mayros.ts +++ b/src/config/types.mayros.ts @@ -70,7 +70,7 @@ export type MayrosConfig = { seamColor?: string; /** TUI color theme preset, or "auto" to detect from the terminal. */ theme?: "auto" | "dark" | "light" | "high-contrast"; - /** Enable vim editing mode in the TUI. */ + /** Deprecated: vim mode is not available. Kept for config backward compat. */ vim?: boolean; /** Custom keybindings for TUI actions. */ keybindings?: Record; diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 4ee2c107..e10f4273 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -148,7 +148,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman }, { name: "vim", - description: "Toggle vim editing mode", + description: "Vim mode (unavailable)", }, { name: "permission", diff --git a/src/tui/components/custom-editor.ts b/src/tui/components/custom-editor.ts index 6cf0ecbc..02095400 100644 --- a/src/tui/components/custom-editor.ts +++ b/src/tui/components/custom-editor.ts @@ -1,7 +1,6 @@ import { Editor, Key, matchesKey } from "@earendil-works/pi-tui"; import type { ClipboardImage } from "../clipboard-image.js"; import type { TuiKeybindingResolver } from "../keybinding-resolver.js"; -import type { VimHandler } from "../vim-handler.js"; const BRACKET_PASTE_START = "\x1b[200~"; const BRACKET_PASTE_END = "\x1b[201~"; @@ -25,7 +24,6 @@ export class CustomEditor extends Editor { onAltEnter?: () => void; onImagePaste?: (image: ImagePasteEvent) => void; tuiResolver?: TuiKeybindingResolver; - vimHandler?: VimHandler; private imageCounter = 0; captureClipboardImage: (() => ClipboardImage | null) | null = null; @@ -58,12 +56,6 @@ export class CustomEditor extends Editor { // No image on clipboard β€” ignore the empty paste return; } - // Vim mode intercept: if vim is in normal mode, consume keys there. - if (this.vimHandler?.isNormalMode()) { - if (this.vimHandler.handleKey(data)) { - return; - } - } if (matchesKey(data, Key.alt("enter")) && this.onAltEnter) { this.onAltEnter(); return; @@ -118,10 +110,6 @@ export class CustomEditor extends Editor { return; } if (matchesKey(data, Key.escape) && !this.isShowingAutocomplete()) { - // In vim insert mode, Escape switches to normal mode - if (this.vimHandler && this.vimHandler.handleKey(data)) { - return; - } if (this.onEscape) { this.onEscape(); return; diff --git a/src/tui/keybinding-resolver.test.ts b/src/tui/keybinding-resolver.test.ts index db5b4c5b..f02a1189 100644 --- a/src/tui/keybinding-resolver.test.ts +++ b/src/tui/keybinding-resolver.test.ts @@ -56,9 +56,9 @@ describe("TuiKeybindingResolver", () => { expect(piTuiMocks.matchesKey).toHaveBeenCalledWith("\x07", "ctrl+g"); }); - it("returns false when action has no keys", () => { + it("returns false when the data does not match any action", () => { const resolver = new TuiKeybindingResolver(); - expect(resolver.matches("\x00", "toggleVim")).toBe(false); + expect(resolver.matches("\x00", "toggleTools")).toBe(false); }); }); @@ -69,7 +69,6 @@ describe("DEFAULT_TUI_KEYBINDINGS", () => { expect(DEFAULT_TUI_KEYBINDINGS).toHaveProperty("selectSession"); expect(DEFAULT_TUI_KEYBINDINGS).toHaveProperty("toggleTools"); expect(DEFAULT_TUI_KEYBINDINGS).toHaveProperty("toggleThinking"); - expect(DEFAULT_TUI_KEYBINDINGS).toHaveProperty("toggleVim"); }); }); diff --git a/src/tui/keybinding-resolver.ts b/src/tui/keybinding-resolver.ts index 669c27ce..bd8c2dd3 100644 --- a/src/tui/keybinding-resolver.ts +++ b/src/tui/keybinding-resolver.ts @@ -12,8 +12,7 @@ export type TuiAction = | "selectModel" | "selectSession" | "toggleTools" - | "toggleThinking" - | "toggleVim"; + | "toggleThinking"; export const DEFAULT_TUI_KEYBINDINGS: Record = { selectAgent: "ctrl+g", @@ -21,7 +20,6 @@ export const DEFAULT_TUI_KEYBINDINGS: Record = { selectSession: "ctrl+p", toggleTools: "ctrl+o", toggleThinking: "ctrl+t", - toggleVim: "ctrl+shift+v", }; export class TuiKeybindingResolver { diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index b67b5091..aa83d21c 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -773,9 +773,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { } break; case "vim": { - const enabled = !state.vimEnabled; - state.vimEnabled = enabled; - chatLog.addSystem(`vim mode ${enabled ? "enabled" : "disabled"}`); + chatLog.addSystem("vim mode is not available in this build"); break; } case "permission": { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index ca5e3eb6..88438ac2 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -34,7 +34,6 @@ import { editorTheme, theme, setThemePreset } from "./theme/theme.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; import { MouseHandler, createMouseInputListener } from "./mouse-handler.js"; -import { VimHandler } from "./vim-handler.js"; import { formatTokens } from "./tui-formatters.js"; import { createLocalShellRunner } from "./tui-local-shell.js"; import { createOverlayHandlers } from "./tui-overlays.js"; @@ -316,8 +315,7 @@ export async function runTui(opts: TuiOptions) { let permissionMode: "auto" | "ask" | "deny" = "auto"; let fastMode = false; let previousThinkingLevel: string | undefined; - let vimEnabled = config.ui?.vim ?? false; - const vimHandler = new VimHandler(); + let vimEnabled = false; const mouseHandler = new MouseHandler({ scrollLines: 3, scrollAcceleration: true, @@ -476,12 +474,6 @@ export async function runTui(opts: TuiOptions) { }, set vimEnabled(value) { vimEnabled = value ?? false; - if (vimEnabled) { - vimHandler.enable(); - } else { - vimHandler.disable(); - } - updateFooter(); }, get permissionMode() { return permissionMode; @@ -560,14 +552,10 @@ export async function runTui(opts: TuiOptions) { const chatLog = new ChatLog(); const editor = new CustomEditor(tui, editorTheme); editor.tuiResolver = tuiResolver; - editor.vimHandler = vimHandler; editor.captureClipboardImage = captureClipboardImage; editor.onImagePaste = (img) => { pendingImages.set(img.marker, { base64: img.base64, mimeType: img.mimeType }); }; - if (vimEnabled) { - vimHandler.enable(); - } // Mouse scroll β†’ ChatLog scroll + re-render mouseHandler.onScroll((direction, lines) => { @@ -818,7 +806,6 @@ export async function runTui(opts: TuiOptions) { const reasoning = sessionInfo.reasoningLevel ?? "off"; const reasoningLabel = reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null; - const vimLabel = vimEnabled ? vimHandler.getModeIndicator() : null; const permLabel = permissionMode !== "auto" ? `perm ${permissionMode}` : null; const fastLabel = fastMode ? "FAST" : null; const footerParts = [ @@ -829,7 +816,6 @@ export async function runTui(opts: TuiOptions) { verbose !== "off" ? `verbose ${verbose}` : null, reasoningLabel, tokens, - vimLabel, permLabel, fastLabel, ].filter(Boolean); diff --git a/src/tui/vim-handler.test.ts b/src/tui/vim-handler.test.ts deleted file mode 100644 index 978639c9..00000000 --- a/src/tui/vim-handler.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { VimHandler } from "./vim-handler.js"; -import type { EditorBridge } from "./vim-handler.js"; - -function createMockBridge(initialText = ""): EditorBridge & { - text: string; - line: number; - col: number; -} { - const state = { text: initialText, line: 0, col: 0 }; - return { - get text() { - return state.text; - }, - set text(v) { - state.text = v; - }, - get line() { - return state.line; - }, - set line(v) { - state.line = v; - }, - get col() { - return state.col; - }, - set col(v) { - state.col = v; - }, - getText: () => state.text, - setText: (v) => { - state.text = v; - }, - getCursorPosition: () => ({ line: state.line, col: state.col }), - setCursorPosition: (l, c) => { - state.line = l; - state.col = c; - }, - }; -} - -describe("VimHandler", () => { - let vim: VimHandler; - let bridge: ReturnType; - - beforeEach(() => { - vim = new VimHandler(); - bridge = createMockBridge("hello world"); - vim.setBridge(bridge); - }); - - describe("mode management", () => { - it("starts in insert mode", () => { - expect(vim.getMode()).toBe("insert"); - expect(vim.isNormalMode()).toBe(false); - }); - - it("switches to normal mode on enable()", () => { - vim.enable(); - expect(vim.getMode()).toBe("normal"); - expect(vim.isNormalMode()).toBe(true); - }); - - it("switches to insert mode on disable()", () => { - vim.enable(); - vim.disable(); - expect(vim.getMode()).toBe("insert"); - }); - - it("shows correct mode indicator", () => { - expect(vim.getModeIndicator()).toBe("-- INSERT --"); - vim.enable(); - expect(vim.getModeIndicator()).toBe("-- NORMAL --"); - }); - }); - - describe("insert mode", () => { - it("does not consume regular keys", () => { - expect(vim.handleKey("a")).toBe(false); - expect(vim.handleKey("x")).toBe(false); - }); - - it("consumes Escape to switch to normal", () => { - expect(vim.handleKey("\x1b")).toBe(true); - expect(vim.getMode()).toBe("normal"); - }); - }); - - describe("normal mode β€” mode switching", () => { - beforeEach(() => { - vim.enable(); - }); - - it("i switches to insert", () => { - expect(vim.handleKey("i")).toBe(true); - expect(vim.getMode()).toBe("insert"); - }); - - it("a switches to insert and moves cursor right", () => { - bridge.col = 2; - vim.handleKey("a"); - expect(vim.getMode()).toBe("insert"); - expect(bridge.col).toBe(3); - }); - - it("I switches to insert and moves to line start", () => { - bridge.col = 5; - vim.handleKey("I"); - expect(vim.getMode()).toBe("insert"); - expect(bridge.col).toBe(0); - }); - - it("A switches to insert and moves to line end", () => { - vim.handleKey("A"); - expect(vim.getMode()).toBe("insert"); - expect(bridge.col).toBe(11); // "hello world".length - }); - - it("o inserts line below and enters insert mode", () => { - vim.handleKey("o"); - expect(vim.getMode()).toBe("insert"); - expect(bridge.text).toBe("hello world\n"); - expect(bridge.line).toBe(1); - }); - - it("O inserts line above and enters insert mode", () => { - vim.handleKey("O"); - expect(vim.getMode()).toBe("insert"); - expect(bridge.text).toBe("\nhello world"); - expect(bridge.line).toBe(0); - }); - }); - - describe("normal mode β€” cursor motions", () => { - beforeEach(() => { - vim.enable(); - bridge.col = 5; - }); - - it("h moves cursor left", () => { - vim.handleKey("h"); - expect(bridge.col).toBe(4); - }); - - it("l moves cursor right", () => { - vim.handleKey("l"); - expect(bridge.col).toBe(6); - }); - - it("0 moves to line start", () => { - vim.handleKey("0"); - expect(bridge.col).toBe(0); - }); - - it("$ moves to line end", () => { - vim.handleKey("$"); - expect(bridge.col).toBe(11); - }); - - it("j moves down", () => { - bridge.text = "line1\nline2"; - bridge.line = 0; - bridge.col = 2; - vim.handleKey("j"); - expect(bridge.line).toBe(1); - }); - - it("k moves up", () => { - bridge.text = "line1\nline2"; - bridge.line = 1; - bridge.col = 0; - vim.handleKey("k"); - expect(bridge.line).toBe(0); - }); - - it("w moves to next word", () => { - bridge.col = 0; - vim.handleKey("w"); - expect(bridge.col).toBe(6); // start of "world" - }); - - it("b moves to previous word start", () => { - bridge.col = 8; - vim.handleKey("b"); - expect(bridge.col).toBe(6); // start of "world" - }); - }); - - describe("normal mode β€” count prefix", () => { - beforeEach(() => { - vim.enable(); - bridge.col = 0; - }); - - it("3l moves cursor 3 right", () => { - vim.handleKey("3"); - vim.handleKey("l"); - expect(bridge.col).toBe(3); - }); - - it("2h moves cursor 2 left", () => { - bridge.col = 5; - vim.handleKey("2"); - vim.handleKey("h"); - expect(bridge.col).toBe(3); - }); - }); - - describe("normal mode β€” editing", () => { - beforeEach(() => { - vim.enable(); - }); - - it("x deletes char at cursor", () => { - bridge.col = 0; - vim.handleKey("x"); - expect(bridge.text).toBe("ello world"); - }); - - it("D deletes to end of line", () => { - bridge.col = 5; - vim.handleKey("D"); - expect(bridge.text).toBe("hello"); - }); - - it("C deletes to end and enters insert", () => { - bridge.col = 5; - vim.handleKey("C"); - expect(bridge.text).toBe("hello"); - expect(vim.getMode()).toBe("insert"); - }); - - it("dd deletes entire line", () => { - bridge.text = "first\nsecond\nthird"; - bridge.line = 1; - vim.handleKey("d"); - vim.handleKey("d"); - expect(bridge.text).toBe("first\nthird"); - }); - - it("cc deletes line and enters insert", () => { - bridge.text = "first\nsecond\nthird"; - bridge.line = 1; - vim.handleKey("c"); - vim.handleKey("c"); - expect(bridge.text).toBe("first\nthird"); - expect(vim.getMode()).toBe("insert"); - }); - - it("yy + p yanks and pastes line", () => { - bridge.text = "hello"; - bridge.col = 2; - vim.handleKey("y"); - vim.handleKey("y"); - vim.handleKey("p"); - // Paste inserts after cursor (col 2): "hel" + "hello" + "lo" - expect(bridge.text).toBe("helhellolo"); - }); - - it("u undoes last edit", () => { - bridge.col = 0; - vim.handleKey("x"); - expect(bridge.text).toBe("ello world"); - vim.handleKey("u"); - expect(bridge.text).toBe("hello world"); - }); - }); - - describe("normal mode β€” key consumption", () => { - beforeEach(() => { - vim.enable(); - }); - - it("all known keys are consumed", () => { - for (const key of [ - "h", - "j", - "k", - "l", - "w", - "b", - "0", - "$", - "i", - "a", - "x", - "u", - "p", - "D", - "C", - ]) { - const result = vim.handleKey(key); - expect(result, `key '${key}' should be consumed`).toBe(true); - vim.enable(); // Reset to normal mode - } - }); - - it("unknown keys are still consumed in normal mode", () => { - expect(vim.handleKey("z")).toBe(true); - }); - }); -}); diff --git a/src/tui/vim-handler.ts b/src/tui/vim-handler.ts deleted file mode 100644 index fa0d2938..00000000 --- a/src/tui/vim-handler.ts +++ /dev/null @@ -1,428 +0,0 @@ -export type VimMode = "normal" | "insert"; - -type MotionResult = - | { - type: "cursor"; - delta: number; - } - | { - type: "line-start" | "line-end" | "word-forward" | "word-backward"; - }; - -type OperatorPending = "d" | "c" | "y" | null; - -export type VimState = { - mode: VimMode; - countPrefix: string; - operator: OperatorPending; - lastYank: string; -}; - -export type EditorBridge = { - getText: () => string; - setText: (value: string) => void; - getCursorPosition: () => { line: number; col: number }; - setCursorPosition: (line: number, col: number) => void; -}; - -export class VimHandler { - private state: VimState; - private bridge: EditorBridge | null = null; - private undoStack: string[] = []; - private maxUndo = 50; - - constructor() { - this.state = { - mode: "insert", - countPrefix: "", - operator: null, - lastYank: "", - }; - } - - setBridge(bridge: EditorBridge): void { - this.bridge = bridge; - } - - getMode(): VimMode { - return this.state.mode; - } - - getModeIndicator(): string { - return this.state.mode === "normal" ? "-- NORMAL --" : "-- INSERT --"; - } - - isNormalMode(): boolean { - return this.state.mode === "normal"; - } - - enable(): void { - this.state.mode = "normal"; - this.state.countPrefix = ""; - this.state.operator = null; - } - - disable(): void { - this.state.mode = "insert"; - this.state.countPrefix = ""; - this.state.operator = null; - } - - /** - * Handle a keypress in vim mode. - * Returns true if the key was consumed (not passed to editor). - */ - handleKey(key: string): boolean { - if (this.state.mode === "insert") { - // Escape β†’ switch to normal mode - if (key === "\x1b" || key === "\u001b") { - this.state.mode = "normal"; - this.state.countPrefix = ""; - this.state.operator = null; - return true; - } - return false; - } - - // Normal mode - return this.handleNormalMode(key); - } - - private getCount(): number { - const n = parseInt(this.state.countPrefix, 10); - this.state.countPrefix = ""; - return isNaN(n) || n < 1 ? 1 : Math.min(n, 999); - } - - private pushUndo(): void { - if (!this.bridge) return; - const text = this.bridge.getText(); - this.undoStack.push(text); - if (this.undoStack.length > this.maxUndo) { - this.undoStack.shift(); - } - } - - private handleNormalMode(key: string): boolean { - // Count prefix accumulation - if (/^[1-9]$/.test(key) || (this.state.countPrefix.length > 0 && /^[0-9]$/.test(key))) { - this.state.countPrefix += key; - return true; - } - - // Operator pending (d, c, y) - if (this.state.operator === null && (key === "d" || key === "c" || key === "y")) { - this.state.operator = key; - return true; - } - - // dd, cc, yy β€” whole-line operations - if (this.state.operator && key === this.state.operator) { - const count = this.getCount(); - this.handleLinewiseOp(this.state.operator, count); - this.state.operator = null; - return true; - } - - // operator + motion - if (this.state.operator) { - const motion = this.resolveMotion(key); - if (motion) { - this.handleOperatorMotion(this.state.operator, motion); - this.state.operator = null; - return true; - } - // Invalid motion β€” cancel operator - this.state.operator = null; - this.state.countPrefix = ""; - return true; - } - - const count = this.getCount(); - - // Mode switches - switch (key) { - case "i": - this.state.mode = "insert"; - return true; - case "a": - this.state.mode = "insert"; - this.moveCursor(1); - return true; - case "I": - this.state.mode = "insert"; - this.moveToLineStart(); - return true; - case "A": - this.state.mode = "insert"; - this.moveToLineEnd(); - return true; - case "o": - this.state.mode = "insert"; - this.insertLineBelow(); - return true; - case "O": - this.state.mode = "insert"; - this.insertLineAbove(); - return true; - } - - // Motions - switch (key) { - case "h": - this.moveCursor(-count); - return true; - case "l": - this.moveCursor(count); - return true; - case "j": - this.moveVertical(count); - return true; - case "k": - this.moveVertical(-count); - return true; - case "w": - for (let i = 0; i < count; i++) this.moveWordForward(); - return true; - case "b": - for (let i = 0; i < count; i++) this.moveWordBackward(); - return true; - case "0": - this.moveToLineStart(); - return true; - case "$": - this.moveToLineEnd(); - return true; - } - - // Editing commands - switch (key) { - case "x": - this.deleteCharsAtCursor(count); - return true; - case "D": - this.deleteToEndOfLine(); - return true; - case "C": - this.deleteToEndOfLine(); - this.state.mode = "insert"; - return true; - case "p": - this.paste(); - return true; - case "u": - this.undo(); - return true; - } - - return true; - } - - private resolveMotion(key: string): MotionResult | null { - switch (key) { - case "h": - return { type: "cursor", delta: -1 }; - case "l": - return { type: "cursor", delta: 1 }; - case "w": - return { type: "word-forward" }; - case "b": - return { type: "word-backward" }; - case "0": - return { type: "line-start" }; - case "$": - return { type: "line-end" }; - default: - return null; - } - } - - private handleLinewiseOp(op: OperatorPending, count: number): void { - if (!this.bridge) return; - this.pushUndo(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const { line } = this.bridge.getCursorPosition(); - const start = Math.min(line, lines.length - 1); - const end = Math.min(start + count, lines.length); - const deleted = lines.splice(start, end - start); - this.state.lastYank = deleted.join("\n"); - - if (op === "d" || op === "c") { - this.bridge.setText(lines.join("\n")); - const newLine = Math.min(start, Math.max(0, lines.length - 1)); - this.bridge.setCursorPosition(newLine, 0); - } - if (op === "c") { - this.state.mode = "insert"; - } - } - - private handleOperatorMotion(op: OperatorPending, _motion: MotionResult): void { - // Simplified: for non-linewise motions, behave like single-char operations - if (!this.bridge) return; - if (op === "d") { - this.deleteCharsAtCursor(1); - } else if (op === "c") { - this.deleteCharsAtCursor(1); - this.state.mode = "insert"; - } else if (op === "y") { - const text = this.bridge.getText(); - const lines = text.split("\n"); - const { line } = this.bridge.getCursorPosition(); - this.state.lastYank = lines[line] ?? ""; - } - } - - private moveCursor(delta: number): void { - if (!this.bridge) return; - const { line, col } = this.bridge.getCursorPosition(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const currentLine = lines[line] ?? ""; - const newCol = Math.max(0, Math.min(currentLine.length, col + delta)); - this.bridge.setCursorPosition(line, newCol); - } - - private moveVertical(delta: number): void { - if (!this.bridge) return; - const { line, col } = this.bridge.getCursorPosition(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const newLine = Math.max(0, Math.min(lines.length - 1, line + delta)); - const targetLine = lines[newLine] ?? ""; - const newCol = Math.min(col, targetLine.length); - this.bridge.setCursorPosition(newLine, newCol); - } - - private moveToLineStart(): void { - if (!this.bridge) return; - const { line } = this.bridge.getCursorPosition(); - this.bridge.setCursorPosition(line, 0); - } - - private moveToLineEnd(): void { - if (!this.bridge) return; - const { line } = this.bridge.getCursorPosition(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const currentLine = lines[line] ?? ""; - this.bridge.setCursorPosition(line, currentLine.length); - } - - private moveWordForward(): void { - if (!this.bridge) return; - const { line, col } = this.bridge.getCursorPosition(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const currentLine = lines[line] ?? ""; - - let newCol = col; - // Skip current word chars - while (newCol < currentLine.length && /\w/.test(currentLine[newCol] ?? "")) { - newCol++; - } - // Skip whitespace - while (newCol < currentLine.length && /\s/.test(currentLine[newCol] ?? "")) { - newCol++; - } - if (newCol >= currentLine.length && line < lines.length - 1) { - this.bridge.setCursorPosition(line + 1, 0); - } else { - this.bridge.setCursorPosition(line, newCol); - } - } - - private moveWordBackward(): void { - if (!this.bridge) return; - const { line, col } = this.bridge.getCursorPosition(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const currentLine = lines[line] ?? ""; - - let newCol = col; - if (newCol > 0) newCol--; - // Skip whitespace backwards - while (newCol > 0 && /\s/.test(currentLine[newCol] ?? "")) { - newCol--; - } - // Skip word chars backwards - while (newCol > 0 && /\w/.test(currentLine[newCol - 1] ?? "")) { - newCol--; - } - if (newCol <= 0 && col === 0 && line > 0) { - const prevLine = lines[line - 1] ?? ""; - this.bridge.setCursorPosition(line - 1, prevLine.length); - } else { - this.bridge.setCursorPosition(line, newCol); - } - } - - private deleteCharsAtCursor(count: number): void { - if (!this.bridge) return; - this.pushUndo(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const { line, col } = this.bridge.getCursorPosition(); - const currentLine = lines[line] ?? ""; - const deleted = currentLine.slice(col, col + count); - this.state.lastYank = deleted; - lines[line] = currentLine.slice(0, col) + currentLine.slice(col + count); - this.bridge.setText(lines.join("\n")); - this.bridge.setCursorPosition(line, Math.min(col, (lines[line] ?? "").length)); - } - - private deleteToEndOfLine(): void { - if (!this.bridge) return; - this.pushUndo(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const { line, col } = this.bridge.getCursorPosition(); - const currentLine = lines[line] ?? ""; - this.state.lastYank = currentLine.slice(col); - lines[line] = currentLine.slice(0, col); - this.bridge.setText(lines.join("\n")); - } - - private insertLineBelow(): void { - if (!this.bridge) return; - this.pushUndo(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const { line } = this.bridge.getCursorPosition(); - lines.splice(line + 1, 0, ""); - this.bridge.setText(lines.join("\n")); - this.bridge.setCursorPosition(line + 1, 0); - } - - private insertLineAbove(): void { - if (!this.bridge) return; - this.pushUndo(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const { line } = this.bridge.getCursorPosition(); - lines.splice(line, 0, ""); - this.bridge.setText(lines.join("\n")); - this.bridge.setCursorPosition(line, 0); - } - - private paste(): void { - if (!this.bridge || !this.state.lastYank) return; - this.pushUndo(); - const text = this.bridge.getText(); - const lines = text.split("\n"); - const { line, col } = this.bridge.getCursorPosition(); - const currentLine = lines[line] ?? ""; - lines[line] = currentLine.slice(0, col + 1) + this.state.lastYank + currentLine.slice(col + 1); - this.bridge.setText(lines.join("\n")); - this.bridge.setCursorPosition(line, col + this.state.lastYank.length); - } - - private undo(): void { - if (!this.bridge || this.undoStack.length === 0) return; - const prev = this.undoStack.pop()!; - this.bridge.setText(prev); - const lines = prev.split("\n"); - this.bridge.setCursorPosition(Math.min(lines.length - 1, 0), 0); - } -}