opencode-vim
Advanced tools
Sorry, the diff of this file is not supported yet
+1
-1
| { | ||
| "name": "opencode-vim", | ||
| "version": "0.0.10", | ||
| "version": "0.0.11", | ||
| "exports": { | ||
@@ -5,0 +5,0 @@ "./tui": { |
+14
-1
@@ -5,3 +5,3 @@ # opencode-vim | ||
|  | ||
|  | ||
@@ -16,2 +16,15 @@ ## Installation | ||
| ## Supported Keys | ||
| | Key | Behavior | | ||
| | --- | --- | | ||
| | `<Esc>`, `<C-[>` | Enter normal mode | | ||
| | `i`, `a`, `A`, `o`, `O` | Return to insert mode | | ||
| | `h`, `j`, `k`, `l`, `w`, `b`, `e`, `$`, `0` | Move through the prompt | | ||
| | `x`, `d`, `c`, `y`, `p`, `u`, `<C-r>` | Edit, yank, paste, undo, redo | | ||
| | `v`, `V` | Visual and visual-line selection | | ||
| | `3w`, `diw`, `ci"`, `yiq`, `dip`, `yib` | Counts and text objects | | ||
| | `<CR>` in normal mode | Submit the prompt | | ||
| | `/vim` | Toggle Vim mode on or off | | ||
| See [docs/configuration.md](./docs/configuration.md) for configuration options and keymap examples. |
+246
-3
| import type { KeyEvent } from "@opentui/core" | ||
| import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke } from "@vimee/core" | ||
| import type { CursorPosition, KeybindDefinition, KeybindMap, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core" | ||
| import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext } from "@vimee/core" | ||
| import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core" | ||
| import type { PromptContext } from "../../prompt/types" | ||
@@ -41,3 +41,3 @@ import { focusedInput, setInput, type EditBufferLike } from "./actions" | ||
| const cursor = hostPosition(map, offset) | ||
| const vimeeKey = keyForVimee(event, key) | ||
| let vimeeKey = keyForVimee(event, key) | ||
| if (!vimeeKey) return false | ||
@@ -74,2 +74,5 @@ | ||
| const visualYankRange = visualYankRangeFor(map) | ||
| vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey | ||
| const textObjectHandled = handleTextObject(vimeeKey, ctx, map) | ||
| if (textObjectHandled !== undefined) return textObjectHandled | ||
| const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds) | ||
@@ -235,2 +238,35 @@ vim = result.newCtx | ||
| function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) { | ||
| if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined | ||
| const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer) | ||
| if (!range) return undefined | ||
| const flashRange = vim.operator === "y" ? motionHostRange(map, range) : undefined | ||
| if (!vim.operator) { | ||
| vim = { ...resetContext(vim), visualAnchor: range.start, cursor: range.end } | ||
| applyActions([{ type: "cursor-move", position: range.end }], ctx, map) | ||
| state.setPending("") | ||
| updateTimeout(ctx) | ||
| log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["cursor-move"] }) | ||
| return true | ||
| } | ||
| if (!textObjectOperator(vim.operator)) return undefined | ||
| const originalCursor = vim.cursor | ||
| const result = executeTextObject(vim.operator, range, buffer, vim) | ||
| if (vim.operator === "y") { | ||
| result.context = { ...result.context, cursor: originalCursor } | ||
| result.actions = result.actions.map((action) => (action.type === "cursor-move" ? { ...action, position: originalCursor } : action)) | ||
| } | ||
| vim = result.context | ||
| applyActions(result.actions, ctx, map) | ||
| if (flashRange) flashYank(ctx, activeMap, { type: "yank", text: result.yankedText }, flashRange) | ||
| syncMode(state, vim.mode) | ||
| state.setPending("") | ||
| updateTimeout(ctx) | ||
| log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) }) | ||
| return true | ||
| } | ||
| function updateTimeout(ctx: PromptContext) { | ||
@@ -376,2 +412,9 @@ if (timer) clearTimeout(timer) | ||
| function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined { | ||
| if (range.linewise) return visualLineRange(map, range.start, range.end) | ||
| const start = vimOffsetFromPosition(map.vimText, range.start) | ||
| const end = vimOffsetFromPosition(map.vimText, range.end) | ||
| return vimOffsetRange(map, start, end) | ||
| } | ||
| function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined { | ||
@@ -579,4 +622,204 @@ const start = hostFromVimOffset(map, Math.min(left, right), "next") | ||
| function textObjectAlias(key: string, ctx: VimContext) { | ||
| if (ctx.phase !== "text-object-pending") return undefined | ||
| if (key === "b") return "(" | ||
| if (key === "B") return "{" | ||
| return undefined | ||
| } | ||
| function textObjectOperator(operator: Operator): operator is "y" | "d" | "c" { | ||
| return operator === "y" || operator === "d" || operator === "c" | ||
| } | ||
| function resolvePromptTextObject(modifier: "i" | "a", key: string, cursor: CursorPosition, buffer: TextBuffer): MotionRange | null { | ||
| if (key === "q") return quoteRange(modifier, cursor, buffer) | ||
| if (key !== "p") return null | ||
| return paragraphRange(modifier, cursor, buffer) | ||
| } | ||
| function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null { | ||
| const pair = quoteObjectPair(cursor, buffer) | ||
| if (!pair) return null | ||
| const start = modifier === "i" ? pair.open + 1 : pair.open | ||
| const end = modifier === "i" ? pair.close - 1 : pair.close | ||
| return { | ||
| start: { line: cursor.line, col: start }, | ||
| end: { line: cursor.line, col: Math.max(start, end) }, | ||
| linewise: false, | ||
| inclusive: true, | ||
| } | ||
| } | ||
| function paragraphRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null { | ||
| const count = buffer.getLineCount() | ||
| if (count === 0) return null | ||
| let start = clamp(cursor.line, 0, count - 1) | ||
| while (start < count && blankLine(buffer.getLine(start))) start++ | ||
| if (start >= count) { | ||
| start = clamp(cursor.line, 0, count - 1) | ||
| while (start >= 0 && blankLine(buffer.getLine(start))) start-- | ||
| } | ||
| if (start < 0 || start >= count) return null | ||
| let end = start | ||
| while (start > 0 && !blankLine(buffer.getLine(start - 1))) start-- | ||
| while (end < count - 1 && !blankLine(buffer.getLine(end + 1))) end++ | ||
| if (modifier === "a") { | ||
| if (end < count - 1 && blankLine(buffer.getLine(end + 1))) { | ||
| end++ | ||
| while (end < count - 1 && blankLine(buffer.getLine(end + 1))) end++ | ||
| } else { | ||
| while (start > 0 && blankLine(buffer.getLine(start - 1))) start-- | ||
| } | ||
| } | ||
| return { | ||
| start: { line: start, col: 0 }, | ||
| end: { line: end, col: Math.max(0, buffer.getLineLength(end) - 1) }, | ||
| linewise: true, | ||
| inclusive: true, | ||
| } | ||
| } | ||
| function quoteObjectPair(cursor: CursorPosition, buffer: TextBuffer) { | ||
| let best: { open: number; close: number; distance: number } | undefined | ||
| for (const quote of ['"', "'", "`"] as const) { | ||
| const pair = quotePair(cursor, buffer, quote) | ||
| if (!pair) continue | ||
| if (!best || pair.distance < best.distance) best = pair | ||
| } | ||
| return best | ||
| } | ||
| function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) { | ||
| const line = buffer.getLine(cursor.line) | ||
| let open = -1 | ||
| let close = -1 | ||
| let inQuote = false | ||
| let quoteStart = -1 | ||
| for (let index = 0; index < line.length; index++) { | ||
| if (line[index] !== quote || escaped(line, index)) continue | ||
| if (!inQuote) { | ||
| quoteStart = index | ||
| inQuote = true | ||
| continue | ||
| } | ||
| if (cursor.col >= quoteStart && cursor.col <= index) { | ||
| return { open: quoteStart, close: index, distance: 0 } | ||
| } | ||
| inQuote = false | ||
| } | ||
| for (let index = cursor.col; index < line.length; index++) { | ||
| if (line[index] !== quote || escaped(line, index)) continue | ||
| if (open === -1) open = index | ||
| else { | ||
| close = index | ||
| break | ||
| } | ||
| } | ||
| if (open !== -1 && close !== -1) return { open, close, distance: open - cursor.col } | ||
| close = -1 | ||
| open = -1 | ||
| for (let index = cursor.col; index >= 0; index--) { | ||
| if (line[index] !== quote || escaped(line, index)) continue | ||
| if (close === -1) close = index | ||
| else { | ||
| open = index | ||
| break | ||
| } | ||
| } | ||
| if (open !== -1 && close !== -1) return { open, close, distance: cursor.col - close } | ||
| return undefined | ||
| } | ||
| function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) { | ||
| buffer.saveUndoPoint(ctx.cursor) | ||
| const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer) : executeCharwiseTextObject(operator, range, buffer) | ||
| const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers | ||
| const context = { | ||
| ...resetContext(ctx), | ||
| mode: result.mode, | ||
| cursor: result.cursor, | ||
| register: result.yankedText, | ||
| registers, | ||
| statusMessage: result.statusMessage, | ||
| } | ||
| const actions: VimeeAction[] = [{ type: "yank", text: result.yankedText }, ...result.actions, { type: "mode-change", mode: result.mode }, { type: "cursor-move", position: result.cursor }] | ||
| return { actions, context, yankedText: result.yankedText } | ||
| } | ||
| function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) { | ||
| const startLine = Math.min(range.start.line, range.end.line) | ||
| const endLine = Math.max(range.start.line, range.end.line) | ||
| const lineCount = endLine - startLine + 1 | ||
| const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n" | ||
| if (operator === "y") { | ||
| return { actions: [] as VimeeAction[], cursor: { line: startLine, col: 0 }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" } | ||
| } | ||
| buffer.deleteLines(startLine, lineCount) | ||
| if (buffer.getLineCount() === 0) buffer.insertLine(0, "") | ||
| const line = Math.min(startLine, buffer.getLineCount() - 1) | ||
| if (operator === "c") buffer.insertLine(line, "") | ||
| return { | ||
| actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[], | ||
| cursor: { line, col: 0 }, | ||
| mode: (operator === "c" ? "insert" : "normal") as VimeeMode, | ||
| yankedText, | ||
| statusMessage: lineCount >= 2 ? `${lineCount} fewer lines` : "", | ||
| } | ||
| } | ||
| function executeCharwiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) { | ||
| const ordered = orderedRange(range) | ||
| const endCol = range.inclusive ? ordered.end.col + 1 : ordered.end.col | ||
| const yankedText = textInRange(buffer, ordered.start, { line: ordered.end.line, col: endCol }) | ||
| if (operator === "y") { | ||
| return { actions: [] as VimeeAction[], cursor: ordered.start, mode: "normal" as VimeeMode, yankedText, statusMessage: yankedText.split("\n").length >= 2 ? `${yankedText.split("\n").length} lines yanked` : "" } | ||
| } | ||
| const linesBefore = buffer.getLineCount() | ||
| buffer.deleteRange(ordered.start.line, ordered.start.col, ordered.end.line, endCol) | ||
| const linesRemoved = linesBefore - buffer.getLineCount() | ||
| return { | ||
| actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[], | ||
| cursor: { line: ordered.start.line, col: operator === "c" ? ordered.start.col : Math.min(ordered.start.col, Math.max(0, buffer.getLineLength(ordered.start.line) - 1)) }, | ||
| mode: (operator === "c" ? "insert" : "normal") as VimeeMode, | ||
| yankedText, | ||
| statusMessage: linesRemoved >= 2 ? `${linesRemoved} fewer lines` : "", | ||
| } | ||
| } | ||
| function orderedRange(range: MotionRange) { | ||
| if (range.start.line > range.end.line || (range.start.line === range.end.line && range.start.col > range.end.col)) { | ||
| return { start: range.end, end: range.start } | ||
| } | ||
| return { start: range.start, end: range.end } | ||
| } | ||
| function textInRange(buffer: TextBuffer, start: CursorPosition, end: CursorPosition) { | ||
| if (start.line === end.line) return buffer.getLine(start.line).slice(start.col, end.col) | ||
| const lines = [buffer.getLine(start.line).slice(start.col)] | ||
| for (let line = start.line + 1; line < end.line; line++) lines.push(buffer.getLine(line)) | ||
| lines.push(buffer.getLine(end.line).slice(0, end.col)) | ||
| return lines.join("\n") | ||
| } | ||
| function blankLine(line: string) { | ||
| return line.trim().length === 0 | ||
| } | ||
| function escaped(line: string, index: number) { | ||
| let slashCount = 0 | ||
| for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor--) slashCount++ | ||
| return slashCount % 2 === 1 | ||
| } | ||
| function clamp(value: number, min: number, max: number) { | ||
| return Math.max(min, Math.min(max, value)) | ||
| } |
Sorry, the diff of this file is not supported yet
1143420
197.51%33
3.13%2677
8.73%29
81.25%