diff --git a/README.md b/README.md index a1f1590..360b393 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 edc609c..e98a983 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 ec33a44..b77abea 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 4ee2c10..e10f427 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 6cf0ecb..0209540 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 db5b4c5..f02a118 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 669c27c..bd8c2dd 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 b67b509..aa83d21 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 ca5e3eb..88438ac 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 978639c..0000000 --- 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 fa0d293..0000000 --- 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); - } -}