mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-15 03:31:05 +08:00
Merge pull request #11428 from drye/feat/cli-vim-mode-prompt
feat(cli): add vim modal editing to the prompt input
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Add vim modal editing to the CLI prompt input. Enable it with `"vim": true` in `tui.jsonc`, the `Toggle vim mode` command in the command palette, or the `/vim` slash command. Supports NORMAL-mode motions (h/j/k/l, w/b/e, 0/^/$, gg/G, counts), edits (x, dd, dw, cw, D, C, r, yy/p, u, Ctrl+r), insert transitions (i/a/A/I/o/O), and VISUAL / VISUAL-LINE mode (v/V with selection-extending motions, d/x/c/s/y, o to swap ends), with a mode indicator and matching cursor shape.
|
||||
@@ -70,6 +70,9 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { type WorkspaceStatus } from "../workspace-label"
|
||||
import { KILO_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
// kilocode_change start - vim modal editing for the prompt
|
||||
import { useVim, VimModeIndicator, vimToggleCommand } from "@/kilocode/cli/cmd/tui/component/prompt"
|
||||
// kilocode_change end
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -209,6 +212,16 @@ export function Prompt(props: PromptProps) {
|
||||
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
|
||||
const [warpNotice, setWarpNotice] = createSignal<string>()
|
||||
const [cursorVersion, setCursorVersion] = createSignal(0)
|
||||
// kilocode_change start - vim modal editing for the prompt
|
||||
const vim = useVim({
|
||||
input: () => input,
|
||||
disabled: () => props.disabled ?? false,
|
||||
autocompleteVisible: () => Boolean(auto()?.visible),
|
||||
getVimEnabled: () => Boolean(kv.get("vim_enabled", tuiConfig.vim ?? false)),
|
||||
bumpCursor: () => setCursorVersion((value) => value + 1),
|
||||
cursorVersion: () => cursorVersion(),
|
||||
})
|
||||
// kilocode_change end
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
@@ -622,6 +635,15 @@ export function Prompt(props: PromptProps) {
|
||||
input.cursorOffset = Bun.stringWidth(content)
|
||||
},
|
||||
},
|
||||
// kilocode_change start - vim modal editing toggle (palette + /vim)
|
||||
vimToggleCommand({
|
||||
vimEnabled: vim.vimEnabled,
|
||||
setVimEnabled: (value) => kv.set("vim_enabled", value),
|
||||
resetVim: vim.resetVim,
|
||||
clearDialog: () => dialog.clear(),
|
||||
showToast: (message) => toast.show({ message, variant: "info" }),
|
||||
}),
|
||||
// kilocode_change end
|
||||
{
|
||||
title: "Skills",
|
||||
name: "prompt.skills",
|
||||
@@ -681,6 +703,7 @@ export function Prompt(props: PromptProps) {
|
||||
"prompt.stash",
|
||||
"prompt.stash.pop",
|
||||
"prompt.stash.list",
|
||||
"prompt.vim.toggle", // kilocode_change
|
||||
"session.interrupt",
|
||||
"workspace.set",
|
||||
]),
|
||||
@@ -713,6 +736,7 @@ export function Prompt(props: PromptProps) {
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
vim.resetVim() // kilocode_change - return to insert mode after the prompt is cleared
|
||||
},
|
||||
submit() {
|
||||
void submit()
|
||||
@@ -865,6 +889,7 @@ export function Prompt(props: PromptProps) {
|
||||
input.clear()
|
||||
setStore("prompt", { input: "", parts: [] })
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
vim.resetVim() // kilocode_change
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -880,6 +905,7 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.gotoBufferEnd()
|
||||
vim.resetVim() // kilocode_change
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -897,6 +923,7 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.gotoBufferEnd()
|
||||
vim.resetVim() // kilocode_change
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -1019,6 +1046,7 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
vim.resetVim() // kilocode_change - recalled history starts in insert mode
|
||||
input.cursorOffset = 0
|
||||
},
|
||||
},
|
||||
@@ -1055,6 +1083,7 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
vim.resetVim() // kilocode_change - recalled history starts in insert mode
|
||||
input.cursorOffset = input.plainText.length
|
||||
},
|
||||
},
|
||||
@@ -1348,6 +1377,7 @@ export function Prompt(props: PromptProps) {
|
||||
}, 50)
|
||||
}
|
||||
input.clear()
|
||||
vim.resetVim() // kilocode_change - drop back to insert mode after sending
|
||||
return true
|
||||
}
|
||||
const exit = useExit()
|
||||
@@ -1508,6 +1538,7 @@ export function Prompt(props: PromptProps) {
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
vim.resetVim() // kilocode_change - don't leak stale vim mode/selection into an emptied prompt
|
||||
}
|
||||
|
||||
// kilocode_change start - cost-alert logic lives under kilocode/; only prompt mutation stays here
|
||||
@@ -1647,11 +1678,18 @@ export function Prompt(props: PromptProps) {
|
||||
setCursorVersion((value) => value + 1)
|
||||
if (store.mode === "normal") auto()?.onCursorChange()
|
||||
}}
|
||||
onKeyDown={(e: { preventDefault(): void }) => {
|
||||
/* kilocode_change - KeyEvent type for vim key routing */ onKeyDown={(e: KeyEvent) => {
|
||||
if (props.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
// kilocode_change start - route keys through the vim layer when enabled
|
||||
if (vim.vimOnKey(e)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
// kilocode_change end
|
||||
}}
|
||||
onSubmit={() => {
|
||||
// IME: double-defer so the last composed character (e.g. Korean
|
||||
@@ -1717,6 +1755,18 @@ export function Prompt(props: PromptProps) {
|
||||
Locale.titlecase(local.agent.current()?.name ?? ""))}{" "}
|
||||
{/* kilocode_change end */}
|
||||
</text>
|
||||
{/* kilocode_change start - vim mode indicator */}
|
||||
<VimModeIndicator
|
||||
when={() => vim.vimEnabled() && store.mode !== "shell"}
|
||||
mode={vim.vimMode}
|
||||
fade={fadeColor}
|
||||
textMuted={() => theme.textMuted}
|
||||
info={() => theme.info}
|
||||
warning={() => theme.warning}
|
||||
success={() => theme.success}
|
||||
alpha={agentMetaAlpha}
|
||||
/>
|
||||
{/* kilocode_change end */}
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
|
||||
|
||||
@@ -213,6 +213,7 @@ export const Definitions = {
|
||||
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
|
||||
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
|
||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||
"prompt.vim.toggle": keybind("none", "Toggle vim modal editing in the prompt input"), // kilocode_change
|
||||
"plugins.toggle": keybind("space", "Toggle plugin"),
|
||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
||||
|
||||
|
||||
@@ -87,4 +87,9 @@ export const TuiInfo = Schema.Struct({
|
||||
scroll_acceleration: Schema.optional(ScrollAcceleration),
|
||||
diff_style: Schema.optional(DiffStyle),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
|
||||
// kilocode_change start - vim modal editing toggle
|
||||
vim: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable vim modal editing in the prompt input (default: false)",
|
||||
}),
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { createEffect, createMemo, createSignal, Show } from "solid-js"
|
||||
import { type KeyEvent, type RGBA, type TextareaRenderable } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import {
|
||||
createVimState,
|
||||
enterNormal,
|
||||
handleNormalKey,
|
||||
handleVisualKey,
|
||||
type VimDoc,
|
||||
type VimKey,
|
||||
type VimMode,
|
||||
} from "./vim"
|
||||
|
||||
export interface VimDeps {
|
||||
input: () => TextareaRenderable | undefined
|
||||
disabled: () => boolean
|
||||
autocompleteVisible: () => boolean
|
||||
getVimEnabled: () => boolean
|
||||
bumpCursor: () => void
|
||||
cursorVersion: () => number
|
||||
}
|
||||
|
||||
export interface VimApi {
|
||||
vimEnabled: () => boolean
|
||||
vimMode: () => VimMode
|
||||
vimOnKey: (e: KeyEvent) => boolean
|
||||
resetVim: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up vim modal editing for the prompt textarea. Returns the reactive
|
||||
* accessors and key handler the shared `Prompt` component wires into its
|
||||
* textarea, command palette, and meta-row indicator.
|
||||
*
|
||||
* Must be called within the `Prompt` component body so the SolidJS effects
|
||||
* are owned by the component.
|
||||
*/
|
||||
export function useVim(deps: VimDeps): VimApi {
|
||||
const state = createVimState("insert")
|
||||
const [mode, setMode] = createSignal<VimMode>("insert")
|
||||
const enabled = createMemo(() => deps.getVimEnabled())
|
||||
|
||||
function sync() {
|
||||
if (state.mode !== mode()) setMode(state.mode)
|
||||
deps.bumpCursor()
|
||||
}
|
||||
|
||||
function resetVim() {
|
||||
state.mode = "insert"
|
||||
state.operator = undefined
|
||||
state.awaitingG = false
|
||||
state.awaitingReplace = false
|
||||
state.countDigits = ""
|
||||
state.desiredColumn = undefined
|
||||
state.visualAnchor = undefined
|
||||
const input = deps.input()
|
||||
if (input && !input.isDestroyed) input.clearSelection()
|
||||
setMode("insert")
|
||||
}
|
||||
|
||||
function doc(input: TextareaRenderable): VimDoc {
|
||||
return {
|
||||
get text() {
|
||||
return input.plainText
|
||||
},
|
||||
get cursor() {
|
||||
return input.cursorOffset
|
||||
},
|
||||
setCursor(offset: number) {
|
||||
input.cursorOffset = Math.max(0, Math.min(offset, input.plainText.length))
|
||||
},
|
||||
insert(offset: number, value: string) {
|
||||
input.cursorOffset = Math.max(0, Math.min(offset, input.plainText.length))
|
||||
input.insertText(value)
|
||||
},
|
||||
remove(start: number, end: number) {
|
||||
const removed = input.plainText.slice(start, end)
|
||||
input.setSelection(start, end)
|
||||
input.deleteSelection()
|
||||
return removed
|
||||
},
|
||||
undo() {
|
||||
input.undo()
|
||||
},
|
||||
redo() {
|
||||
input.redo()
|
||||
},
|
||||
setSelection(start: number, end: number) {
|
||||
input.setSelection(start, end)
|
||||
},
|
||||
clearSelection() {
|
||||
input.clearSelection()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept a key while vim mode is active. Returns true when the key was
|
||||
* consumed by the vim layer (caller must preventDefault so the textarea does
|
||||
* not also process it).
|
||||
*/
|
||||
function vimOnKey(e: KeyEvent): boolean {
|
||||
const input = deps.input()
|
||||
if (!enabled() || deps.disabled() || !input || input.isDestroyed) return false
|
||||
|
||||
// INSERT mode: only Escape is special (switch to NORMAL). Everything else
|
||||
// is left to the native textarea so typing behaves normally.
|
||||
if (state.mode === "insert") {
|
||||
if (e.name === "escape" && !deps.autocompleteVisible()) {
|
||||
enterNormal(doc(input), state)
|
||||
sync()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const visual = state.mode === "visual" || state.mode === "visual-line"
|
||||
|
||||
// In NORMAL mode keep Enter (submit) and Tab (autocomplete) working rather
|
||||
// than emulating strict vim line motions for them. In VISUAL mode the user
|
||||
// is selecting, so let the engine handle those keys instead.
|
||||
if (!visual && (e.name === "return" || e.name === "enter" || e.name === "tab")) return false
|
||||
|
||||
// Let global ctrl/meta combos (e.g. ctrl+c to exit) through, except ctrl+r
|
||||
// which is vim redo.
|
||||
const ctrl = e.ctrl === true
|
||||
if ((ctrl || e.meta === true || e.super === true) && !(ctrl && e.name === "r")) return false
|
||||
|
||||
const key: VimKey = ctrl
|
||||
? { key: e.name, ctrl: true }
|
||||
: { key: e.sequence && e.sequence.length === 1 ? e.sequence : e.name }
|
||||
|
||||
const result = visual ? handleVisualKey(doc(input), state, key) : handleNormalKey(doc(input), state, key)
|
||||
if (result.handled) sync()
|
||||
return result.handled
|
||||
}
|
||||
|
||||
// Block cursor in NORMAL/VISUAL modes, bar cursor in INSERT mode (vim
|
||||
// convention). Also restores the default cursor when vim is disabled.
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
if (state.mode !== "insert") resetVim()
|
||||
const input = deps.input()
|
||||
if (input && !input.isDestroyed) input.cursorStyle = { style: "block", blinking: true }
|
||||
return
|
||||
}
|
||||
deps.cursorVersion()
|
||||
const input = deps.input()
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorStyle = mode() === "insert" ? { style: "line", blinking: true } : { style: "block", blinking: false }
|
||||
})
|
||||
|
||||
return { vimEnabled: enabled, vimMode: mode, vimOnKey, resetVim }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode indicator shown in the prompt meta row. Renders nothing unless vim mode
|
||||
* is enabled and the prompt is not in shell mode.
|
||||
*/
|
||||
export function VimModeIndicator(props: {
|
||||
when: () => boolean
|
||||
mode: () => VimMode
|
||||
fade: (color: RGBA, alpha: number) => RGBA
|
||||
textMuted: () => RGBA
|
||||
info: () => RGBA
|
||||
warning: () => RGBA
|
||||
success: () => RGBA
|
||||
alpha: () => number
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Show when={props.when()}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={props.fade(props.textMuted(), props.alpha())}>·</text>
|
||||
<text>
|
||||
<span
|
||||
style={{
|
||||
fg: props.fade(
|
||||
props.mode() === "insert"
|
||||
? props.info()
|
||||
: props.mode() === "visual" || props.mode() === "visual-line"
|
||||
? props.warning()
|
||||
: props.success(),
|
||||
props.alpha(),
|
||||
),
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{props.mode() === "insert"
|
||||
? "INSERT"
|
||||
: props.mode() === "visual"
|
||||
? "VISUAL"
|
||||
: props.mode() === "visual-line"
|
||||
? "V-LINE"
|
||||
: "NORMAL"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Command-palette entry that toggles vim mode on or off.
|
||||
*/
|
||||
export function vimToggleCommand(opts: {
|
||||
vimEnabled: () => boolean
|
||||
setVimEnabled: (value: boolean) => void
|
||||
resetVim: () => void
|
||||
clearDialog: () => void
|
||||
showToast: (message: string) => void
|
||||
}) {
|
||||
return {
|
||||
title: "Toggle vim mode",
|
||||
desc: "Enable or disable vim modal editing in the prompt input",
|
||||
name: "prompt.vim.toggle",
|
||||
category: "Prompt",
|
||||
slashName: "vim",
|
||||
run: () => {
|
||||
const next = !opts.vimEnabled()
|
||||
opts.setVimEnabled(next)
|
||||
opts.resetVim()
|
||||
opts.clearDialog()
|
||||
opts.showToast(next ? "Vim mode enabled" : "Vim mode disabled")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
/**
|
||||
* A small, self-contained vim emulation engine for the prompt textarea.
|
||||
*
|
||||
* The engine is intentionally decoupled from the OpenTUI renderable so it can be
|
||||
* unit-tested against a plain in-memory document. The prompt component adapts the
|
||||
* live `TextareaRenderable` to the {@link VimDoc} interface (see `index.tsx`).
|
||||
*
|
||||
* Supported (NORMAL mode):
|
||||
* motions: h j k l, w W b B e, 0 ^ $, gg G, with numeric counts (e.g. 3w, 5j)
|
||||
* edits: x X, D C, s, r{char}, dd cc yy, d/c/y + motion, p P
|
||||
* inserts: i I a A o O
|
||||
* history: u (undo), <C-r> (redo)
|
||||
* visual: v (charwise), V (linewise); motions extend the selection,
|
||||
* d/x/c/s/y operate on it, o swaps ends, Esc/v/V exit
|
||||
*
|
||||
* This is a practical subset, not a complete vim. A few behaviours are
|
||||
* approximations of real vim and are documented inline.
|
||||
*/
|
||||
|
||||
export type VimMode = "insert" | "normal" | "visual" | "visual-line"
|
||||
|
||||
/**
|
||||
* Minimal mutable document the engine operates on. Offsets are character
|
||||
* offsets into `text`, in the range [0, text.length].
|
||||
*/
|
||||
export interface VimDoc {
|
||||
readonly text: string
|
||||
readonly cursor: number
|
||||
setCursor(offset: number): void
|
||||
/** Insert `value` at `offset`. The engine sets the cursor separately. */
|
||||
insert(offset: number, value: string): void
|
||||
/** Remove the half-open range [start, end) and return the removed text. */
|
||||
remove(start: number, end: number): string
|
||||
undo(): void
|
||||
redo(): void
|
||||
/** Highlight the half-open range [start, end) (used to show a visual selection). */
|
||||
setSelection(start: number, end: number): void
|
||||
/** Clear any visual selection highlight. */
|
||||
clearSelection(): void
|
||||
}
|
||||
|
||||
export interface VimRegister {
|
||||
text: string
|
||||
linewise: boolean
|
||||
}
|
||||
|
||||
export interface VimState {
|
||||
mode: VimMode
|
||||
/** Digits accumulated for a numeric count, e.g. "12". */
|
||||
countDigits: string
|
||||
/** Pending operator awaiting a motion. */
|
||||
operator?: "d" | "c" | "y"
|
||||
/** `g` was pressed, waiting for the second key (e.g. `gg`). */
|
||||
awaitingG: boolean
|
||||
/** `r` was pressed, waiting for the replacement character. */
|
||||
awaitingReplace: boolean
|
||||
register: VimRegister
|
||||
/** Sticky column preserved across consecutive `j`/`k` moves (vim behaviour). */
|
||||
desiredColumn?: number
|
||||
/** Fixed end of the selection in visual modes; the cursor is the moving end. */
|
||||
visualAnchor?: number
|
||||
}
|
||||
|
||||
export function createVimState(mode: VimMode = "insert"): VimState {
|
||||
return {
|
||||
mode,
|
||||
countDigits: "",
|
||||
awaitingG: false,
|
||||
awaitingReplace: false,
|
||||
register: { text: "", linewise: false },
|
||||
}
|
||||
}
|
||||
|
||||
export interface VimKey {
|
||||
/** The literal character or named key (e.g. "w", "$", "escape"). */
|
||||
key: string
|
||||
ctrl?: boolean
|
||||
}
|
||||
|
||||
export interface VimResult {
|
||||
/** Whether the engine consumed the key (caller should preventDefault). */
|
||||
handled: boolean
|
||||
/** True when the engine switched into insert mode as a result of this key. */
|
||||
enteredInsert?: boolean
|
||||
}
|
||||
|
||||
// --- character classification --------------------------------------------
|
||||
|
||||
// Character classes for word motions (avoid `const enum` so the module stays
|
||||
// safe under isolatedModules / esbuild per-file transpilation).
|
||||
const CharClass = { Blank: 0, Word: 1, Punct: 2 } as const
|
||||
type CharClass = (typeof CharClass)[keyof typeof CharClass]
|
||||
|
||||
function classOf(ch: string): CharClass {
|
||||
if (ch === "" || /\s/.test(ch)) return CharClass.Blank
|
||||
if (/[A-Za-z0-9_]/.test(ch)) return CharClass.Word
|
||||
return CharClass.Punct
|
||||
}
|
||||
|
||||
function isBlank(ch: string): boolean {
|
||||
return ch === "" || /\s/.test(ch)
|
||||
}
|
||||
|
||||
// --- line helpers ----------------------------------------------------------
|
||||
|
||||
export function lineStart(text: string, pos: number): number {
|
||||
if (pos <= 0) return 0
|
||||
const idx = text.lastIndexOf("\n", pos - 1)
|
||||
return idx === -1 ? 0 : idx + 1
|
||||
}
|
||||
|
||||
export function lineEnd(text: string, pos: number): number {
|
||||
const idx = text.indexOf("\n", pos)
|
||||
return idx === -1 ? text.length : idx
|
||||
}
|
||||
|
||||
function firstNonBlank(text: string, pos: number): number {
|
||||
const start = lineStart(text, pos)
|
||||
const end = lineEnd(text, pos)
|
||||
let i = start
|
||||
while (i < end && /\s/.test(text[i]!)) i++
|
||||
return i < end ? i : start
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a NORMAL-mode cursor so it rests on a character rather than past the
|
||||
* end of the line (vim never lets the block cursor sit on the trailing
|
||||
* newline of a non-empty line).
|
||||
*/
|
||||
export function clampNormal(text: string, pos: number): number {
|
||||
if (text.length === 0) return 0
|
||||
let p = Math.max(0, Math.min(pos, text.length))
|
||||
const start = lineStart(text, p)
|
||||
const end = lineEnd(text, p)
|
||||
if (end > start && p >= end) p = end - 1
|
||||
return p
|
||||
}
|
||||
|
||||
// --- word motions ----------------------------------------------------------
|
||||
|
||||
function wordForward(text: string, pos: number, big: boolean): number {
|
||||
const n = text.length
|
||||
if (pos >= n) return n
|
||||
const startClass = big ? (isBlank(text[pos]!) ? CharClass.Blank : CharClass.Word) : classOf(text[pos]!)
|
||||
let i = pos
|
||||
// Skip the current run (same class) unless we're on whitespace.
|
||||
if (startClass !== CharClass.Blank) {
|
||||
const matches = (ch: string) => (big ? !isBlank(ch) : classOf(ch) === startClass)
|
||||
while (i < n && matches(text[i]!)) i++
|
||||
}
|
||||
// Skip following whitespace.
|
||||
while (i < n && isBlank(text[i]!)) i++
|
||||
return i
|
||||
}
|
||||
|
||||
function wordBackward(text: string, pos: number, big: boolean): number {
|
||||
if (pos <= 0) return 0
|
||||
let i = pos - 1
|
||||
// Skip whitespace to the left.
|
||||
while (i > 0 && isBlank(text[i]!)) i--
|
||||
if (i <= 0) return 0
|
||||
const targetClass = big ? CharClass.Word : classOf(text[i]!)
|
||||
const matches = (ch: string) => (big ? !isBlank(ch) : classOf(ch) === targetClass)
|
||||
while (i > 0 && matches(text[i - 1]!)) i--
|
||||
return i
|
||||
}
|
||||
|
||||
function wordEnd(text: string, pos: number, big: boolean): number {
|
||||
const n = text.length
|
||||
if (pos >= n - 1) return Math.max(0, n - 1)
|
||||
let i = pos + 1
|
||||
// Skip whitespace.
|
||||
while (i < n && isBlank(text[i]!)) i++
|
||||
if (i >= n) return n - 1
|
||||
const targetClass = big ? CharClass.Word : classOf(text[i]!)
|
||||
const matches = (ch: string) => (big ? !isBlank(ch) : classOf(ch) === targetClass)
|
||||
while (i + 1 < n && matches(text[i + 1]!)) i++
|
||||
return i
|
||||
}
|
||||
|
||||
function verticalMove(text: string, pos: number, delta: number, col?: number): number {
|
||||
const start = lineStart(text, pos)
|
||||
const column = col ?? pos - start
|
||||
if (delta < 0) {
|
||||
if (start === 0) return pos
|
||||
const prevEnd = start - 1
|
||||
const prevStart = lineStart(text, prevEnd)
|
||||
return Math.min(prevStart + column, prevEnd)
|
||||
}
|
||||
const end = lineEnd(text, pos)
|
||||
if (end >= text.length) return pos
|
||||
const nextStart = end + 1
|
||||
const nextEnd = lineEnd(text, nextStart)
|
||||
return Math.min(nextStart + column, nextEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the cursor vertically `reps` lines, preserving a sticky desired column
|
||||
* across consecutive moves (matching vim's behaviour where a short
|
||||
* intermediate line does not permanently shrink the column).
|
||||
*/
|
||||
function moveVertical(doc: VimDoc, state: VimState, delta: number, reps: number) {
|
||||
const text = doc.text
|
||||
if (state.desiredColumn === undefined) {
|
||||
state.desiredColumn = doc.cursor - lineStart(text, doc.cursor)
|
||||
}
|
||||
let t = doc.cursor
|
||||
for (let i = 0; i < reps; i++) t = verticalMove(text, t, delta, state.desiredColumn)
|
||||
doc.setCursor(clampNormal(text, t))
|
||||
}
|
||||
|
||||
// --- motion resolution -----------------------------------------------------
|
||||
|
||||
interface Motion {
|
||||
/** Target offset (cursor destination, or operator boundary). */
|
||||
target: number
|
||||
/** Inclusive motions (e.g. `e`, `$`) include the target char when used with an operator. */
|
||||
inclusive: boolean
|
||||
/** Linewise motions (e.g. `j`, `G`) operate on whole lines. */
|
||||
linewise: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single-key motion. Returns undefined for keys that are not motions.
|
||||
*/
|
||||
function resolveMotion(text: string, pos: number, key: string, count: number, awaitingG: boolean): Motion | undefined {
|
||||
const reps = Math.max(1, count)
|
||||
if (awaitingG) {
|
||||
if (key === "g") {
|
||||
// gg -> first line (or `count`gg -> line `count`)
|
||||
let target = 0
|
||||
if (count > 0) {
|
||||
target = 0
|
||||
for (let line = 1; line < count; line++) target = Math.min(text.length, lineEnd(text, target) + 1)
|
||||
}
|
||||
return { target: firstNonBlank(text, target), inclusive: false, linewise: true }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "h": {
|
||||
let t = pos
|
||||
const start = lineStart(text, pos)
|
||||
for (let i = 0; i < reps && t > start; i++) t--
|
||||
return { target: t, inclusive: false, linewise: false }
|
||||
}
|
||||
case "l":
|
||||
case " ": {
|
||||
let t = pos
|
||||
const end = lineEnd(text, pos)
|
||||
for (let i = 0; i < reps && t < end; i++) t++
|
||||
return { target: t, inclusive: false, linewise: false }
|
||||
}
|
||||
case "j": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = verticalMove(text, t, +1)
|
||||
return { target: t, inclusive: false, linewise: true }
|
||||
}
|
||||
case "k": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = verticalMove(text, t, -1)
|
||||
return { target: t, inclusive: false, linewise: true }
|
||||
}
|
||||
case "w": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = wordForward(text, t, false)
|
||||
return { target: t, inclusive: false, linewise: false }
|
||||
}
|
||||
case "W": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = wordForward(text, t, true)
|
||||
return { target: t, inclusive: false, linewise: false }
|
||||
}
|
||||
case "b": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = wordBackward(text, t, false)
|
||||
return { target: t, inclusive: false, linewise: false }
|
||||
}
|
||||
case "B": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = wordBackward(text, t, true)
|
||||
return { target: t, inclusive: false, linewise: false }
|
||||
}
|
||||
case "e": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = wordEnd(text, t, false)
|
||||
return { target: t, inclusive: true, linewise: false }
|
||||
}
|
||||
case "E": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = wordEnd(text, t, true)
|
||||
return { target: t, inclusive: true, linewise: false }
|
||||
}
|
||||
case "0":
|
||||
return { target: lineStart(text, pos), inclusive: false, linewise: false }
|
||||
case "^":
|
||||
return { target: firstNonBlank(text, pos), inclusive: false, linewise: false }
|
||||
case "$": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = lineEnd(text, lineEnd(text, t) + (i === 0 ? 0 : 1))
|
||||
return { target: lineEnd(text, t), inclusive: true, linewise: false }
|
||||
}
|
||||
case "G": {
|
||||
let target = 0
|
||||
if (count > 0) {
|
||||
for (let line = 1; line < count; line++) target = Math.min(text.length, lineEnd(text, target) + 1)
|
||||
} else {
|
||||
target = text.length
|
||||
}
|
||||
return { target: firstNonBlank(text, target), inclusive: false, linewise: true }
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// --- operators -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a `d`/`c`/`y` operator to whole lines spanning [start, contentEnd],
|
||||
* where `contentEnd` is the offset of the last line's trailing newline (or EOF
|
||||
* when the last line has none). Centralises the fiddly EOF rules so delete,
|
||||
* change and yank stay consistent:
|
||||
* - yank/delete take the line(s) plus one adjoining newline (trailing, or the
|
||||
* leading one when deleting through EOF) and the register is always
|
||||
* newline-terminated so `p` pastes as full lines;
|
||||
* - change removes only the line *content*, leaving an empty line to type on.
|
||||
*/
|
||||
function linewiseOperate(
|
||||
doc: VimDoc,
|
||||
state: VimState,
|
||||
operator: "d" | "c" | "y",
|
||||
start: number,
|
||||
contentEnd: number,
|
||||
): VimResult {
|
||||
const text = doc.text
|
||||
const hasTrailingNewline = contentEnd < text.length
|
||||
const yanked = text.slice(start, hasTrailingNewline ? contentEnd + 1 : contentEnd)
|
||||
state.register = { text: yanked.endsWith("\n") ? yanked : yanked + "\n", linewise: true }
|
||||
|
||||
if (operator === "y") {
|
||||
doc.setCursor(clampNormal(text, start))
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
if (operator === "c") {
|
||||
// Keep the line, clear its content, and type on the now-empty line.
|
||||
if (contentEnd > start) doc.remove(start, contentEnd)
|
||||
doc.setCursor(start)
|
||||
state.mode = "insert"
|
||||
return { handled: true, enteredInsert: true }
|
||||
}
|
||||
|
||||
// delete: remove the line(s) and one adjoining newline.
|
||||
const delStart = hasTrailingNewline ? start : start > 0 ? start - 1 : 0
|
||||
const delEnd = hasTrailingNewline ? contentEnd + 1 : contentEnd
|
||||
doc.remove(delStart, delEnd)
|
||||
doc.setCursor(clampNormal(doc.text, delStart))
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
function applyOperator(
|
||||
doc: VimDoc,
|
||||
state: VimState,
|
||||
operator: "d" | "c" | "y",
|
||||
motion: Motion,
|
||||
pos: number,
|
||||
): VimResult {
|
||||
const text = doc.text
|
||||
|
||||
if (motion.linewise) {
|
||||
const a = Math.min(pos, motion.target)
|
||||
const b = Math.max(pos, motion.target)
|
||||
return linewiseOperate(doc, state, operator, lineStart(text, a), lineEnd(text, b))
|
||||
}
|
||||
|
||||
const start = Math.min(pos, motion.target)
|
||||
let end = Math.max(pos, motion.target)
|
||||
if (motion.inclusive) end = Math.min(text.length, end + 1)
|
||||
|
||||
if (start === end) return { handled: true }
|
||||
|
||||
state.register = { text: text.slice(start, end), linewise: false }
|
||||
|
||||
if (operator === "y") {
|
||||
doc.setCursor(clampNormal(text, start))
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
doc.remove(start, end)
|
||||
if (operator === "c") {
|
||||
doc.setCursor(start)
|
||||
state.mode = "insert"
|
||||
return { handled: true, enteredInsert: true }
|
||||
}
|
||||
|
||||
doc.setCursor(clampNormal(doc.text, start))
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
function doubledOperator(doc: VimDoc, state: VimState, operator: "d" | "c" | "y"): VimResult {
|
||||
// dd / cc / yy — linewise on the current (and `count`) lines.
|
||||
const text = doc.text
|
||||
const reps = Math.max(1, parseInt(state.countDigits || "1", 10))
|
||||
const start = lineStart(text, doc.cursor)
|
||||
let contentEnd = lineEnd(text, start)
|
||||
for (let i = 1; i < reps; i++) {
|
||||
if (contentEnd >= text.length) break
|
||||
contentEnd = lineEnd(text, contentEnd + 1)
|
||||
}
|
||||
return linewiseOperate(doc, state, operator, start, contentEnd)
|
||||
}
|
||||
|
||||
function paste(doc: VimDoc, state: VimState, after: boolean): VimResult {
|
||||
const reg = state.register
|
||||
if (!reg.text) return { handled: true }
|
||||
const payload = repeat(reg.text, Math.max(1, parseInt(state.countDigits || "1", 10)))
|
||||
if (reg.linewise) {
|
||||
const body = payload.endsWith("\n") ? payload : payload + "\n"
|
||||
if (after) {
|
||||
const le = lineEnd(doc.text, doc.cursor)
|
||||
if (le < doc.text.length) {
|
||||
// Normal case: paste a new line after the current line's newline.
|
||||
doc.insert(le + 1, body)
|
||||
doc.setCursor(clampNormal(doc.text, le + 1))
|
||||
} else {
|
||||
// Current line is the final line and has no trailing newline; add the
|
||||
// separator ourselves so the pasted line is not merged onto it.
|
||||
const core = body.endsWith("\n") ? body.slice(0, -1) : body
|
||||
const at = doc.text.length
|
||||
doc.insert(at, "\n" + core)
|
||||
doc.setCursor(clampNormal(doc.text, at + 1))
|
||||
}
|
||||
} else {
|
||||
const at = lineStart(doc.text, doc.cursor)
|
||||
doc.insert(at, body)
|
||||
doc.setCursor(clampNormal(doc.text, at))
|
||||
}
|
||||
return { handled: true }
|
||||
}
|
||||
const at = after ? Math.min(doc.text.length, doc.cursor + (doc.text.length === 0 ? 0 : 1)) : doc.cursor
|
||||
doc.insert(at, payload)
|
||||
doc.setCursor(clampNormal(doc.text, at + payload.length - 1))
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
function repeat(value: string, times: number): string {
|
||||
let out = ""
|
||||
for (let i = 0; i < times; i++) out += value
|
||||
return out
|
||||
}
|
||||
|
||||
// --- main entry point ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Process a key in NORMAL mode. Mutates `state` and `doc`. The caller is
|
||||
* responsible for routing INSERT-mode keys to the native textarea and only
|
||||
* invoking this when `state.mode === "normal"` (plus escape handling, see
|
||||
* {@link enterNormal}).
|
||||
*/
|
||||
export function handleNormalKey(doc: VimDoc, state: VimState, input: VimKey): VimResult {
|
||||
const { key } = input
|
||||
|
||||
if (input.ctrl && key === "r") {
|
||||
doc.redo()
|
||||
resetPending(state)
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
if (key === "escape") {
|
||||
resetPending(state)
|
||||
doc.setCursor(clampNormal(doc.text, doc.cursor))
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Pending `r{char}` replace.
|
||||
if (state.awaitingReplace) {
|
||||
state.awaitingReplace = false
|
||||
if (key.length === 1) {
|
||||
const reps = Math.max(1, parseInt(state.countDigits || "1", 10))
|
||||
const start = doc.cursor
|
||||
const end = Math.min(doc.text.length, lineEnd(doc.text, start), start + reps)
|
||||
if (end > start) {
|
||||
doc.remove(start, end)
|
||||
doc.insert(start, repeat(key, end - start))
|
||||
doc.setCursor(clampNormal(doc.text, start + (end - start) - 1))
|
||||
}
|
||||
}
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Numeric count (a leading 0 is the line-start motion, not a count).
|
||||
if (/[0-9]/.test(key) && !(key === "0" && state.countDigits === "")) {
|
||||
state.countDigits += key
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
const count = state.countDigits ? parseInt(state.countDigits, 10) : 0
|
||||
|
||||
// Operator already pending: handle doubled operator or motion.
|
||||
if (state.operator) {
|
||||
const op = state.operator
|
||||
if (state.awaitingG && key !== "g") {
|
||||
resetPending(state)
|
||||
return { handled: true }
|
||||
}
|
||||
// Doubled operator (dd, cc, yy).
|
||||
if (!state.awaitingG && key === op) {
|
||||
const result = doubledOperator(doc, state, op)
|
||||
const enteredInsert = result.enteredInsert
|
||||
resetPending(state)
|
||||
if (enteredInsert) state.mode = "insert"
|
||||
return result
|
||||
}
|
||||
if (key === "g" && !state.awaitingG) {
|
||||
state.awaitingG = true
|
||||
return { handled: true }
|
||||
}
|
||||
// vim special case: `cw`/`cW` behave like `ce`/`cE` (change to word end).
|
||||
const motionKey = op === "c" && !state.awaitingG && (key === "w" || key === "W") ? (key === "w" ? "e" : "E") : key
|
||||
const motion = resolveMotion(doc.text, doc.cursor, motionKey, count, state.awaitingG)
|
||||
if (!motion) {
|
||||
resetPending(state)
|
||||
return { handled: true }
|
||||
}
|
||||
const result = applyOperator(doc, state, op, motion, doc.cursor)
|
||||
const enteredInsert = result.enteredInsert
|
||||
resetPending(state)
|
||||
if (enteredInsert) state.mode = "insert"
|
||||
return result
|
||||
}
|
||||
|
||||
// `g` prefix (gg).
|
||||
if (key === "g" && !state.awaitingG) {
|
||||
state.awaitingG = true
|
||||
return { handled: true }
|
||||
}
|
||||
if (state.awaitingG) {
|
||||
const motion = resolveMotion(doc.text, doc.cursor, key, count, true)
|
||||
state.awaitingG = false
|
||||
state.countDigits = ""
|
||||
if (motion) doc.setCursor(clampNormal(doc.text, motion.target))
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Operators.
|
||||
if (key === "d" || key === "c" || key === "y") {
|
||||
state.operator = key
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Visual mode entry.
|
||||
if (key === "v" || key === "V") {
|
||||
state.mode = key === "v" ? "visual" : "visual-line"
|
||||
state.visualAnchor = doc.cursor
|
||||
state.countDigits = ""
|
||||
updateVisualSelection(doc, state)
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Insert-mode transitions.
|
||||
switch (key) {
|
||||
case "i":
|
||||
state.mode = "insert"
|
||||
resetPending(state)
|
||||
return { handled: true, enteredInsert: true }
|
||||
case "I":
|
||||
doc.setCursor(firstNonBlank(doc.text, doc.cursor))
|
||||
state.mode = "insert"
|
||||
resetPending(state)
|
||||
return { handled: true, enteredInsert: true }
|
||||
case "a":
|
||||
doc.setCursor(Math.min(doc.text.length, doc.cursor + (doc.text.length === 0 ? 0 : 1)))
|
||||
state.mode = "insert"
|
||||
resetPending(state)
|
||||
return { handled: true, enteredInsert: true }
|
||||
case "A":
|
||||
doc.setCursor(lineEnd(doc.text, doc.cursor))
|
||||
state.mode = "insert"
|
||||
resetPending(state)
|
||||
return { handled: true, enteredInsert: true }
|
||||
case "o": {
|
||||
const end = lineEnd(doc.text, doc.cursor)
|
||||
doc.insert(end, "\n")
|
||||
doc.setCursor(end + 1)
|
||||
state.mode = "insert"
|
||||
resetPending(state)
|
||||
return { handled: true, enteredInsert: true }
|
||||
}
|
||||
case "O": {
|
||||
const start = lineStart(doc.text, doc.cursor)
|
||||
doc.insert(start, "\n")
|
||||
doc.setCursor(start)
|
||||
state.mode = "insert"
|
||||
resetPending(state)
|
||||
return { handled: true, enteredInsert: true }
|
||||
}
|
||||
}
|
||||
|
||||
// Single-key edits.
|
||||
switch (key) {
|
||||
case "x": {
|
||||
const reps = Math.max(1, count)
|
||||
const start = doc.cursor
|
||||
const end = Math.min(lineEnd(doc.text, start), start + reps)
|
||||
if (end > start) {
|
||||
state.register = { text: doc.text.slice(start, end), linewise: false }
|
||||
doc.remove(start, end)
|
||||
doc.setCursor(clampNormal(doc.text, start))
|
||||
}
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
case "X": {
|
||||
const reps = Math.max(1, count)
|
||||
const lstart = lineStart(doc.text, doc.cursor)
|
||||
const start = Math.max(lstart, doc.cursor - reps)
|
||||
if (doc.cursor > start) {
|
||||
state.register = { text: doc.text.slice(start, doc.cursor), linewise: false }
|
||||
doc.remove(start, doc.cursor)
|
||||
doc.setCursor(clampNormal(doc.text, start))
|
||||
}
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
case "D": {
|
||||
const start = doc.cursor
|
||||
const end = lineEnd(doc.text, start)
|
||||
if (end > start) {
|
||||
state.register = { text: doc.text.slice(start, end), linewise: false }
|
||||
doc.remove(start, end)
|
||||
doc.setCursor(clampNormal(doc.text, start))
|
||||
}
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
case "C": {
|
||||
const start = doc.cursor
|
||||
const end = lineEnd(doc.text, start)
|
||||
if (end > start) {
|
||||
state.register = { text: doc.text.slice(start, end), linewise: false }
|
||||
doc.remove(start, end)
|
||||
}
|
||||
doc.setCursor(start)
|
||||
state.mode = "insert"
|
||||
state.countDigits = ""
|
||||
return { handled: true, enteredInsert: true }
|
||||
}
|
||||
case "s": {
|
||||
const reps = Math.max(1, count)
|
||||
const start = doc.cursor
|
||||
const end = Math.min(lineEnd(doc.text, start), start + reps)
|
||||
if (end > start) {
|
||||
state.register = { text: doc.text.slice(start, end), linewise: false }
|
||||
doc.remove(start, end)
|
||||
}
|
||||
doc.setCursor(start)
|
||||
state.mode = "insert"
|
||||
state.countDigits = ""
|
||||
return { handled: true, enteredInsert: true }
|
||||
}
|
||||
case "r":
|
||||
state.awaitingReplace = true
|
||||
return { handled: true }
|
||||
case "p":
|
||||
return finishSimple(state, paste(doc, state, true))
|
||||
case "P":
|
||||
return finishSimple(state, paste(doc, state, false))
|
||||
case "u":
|
||||
doc.undo()
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Plain vertical motions preserve a sticky desired column.
|
||||
if (key === "j" || key === "k") {
|
||||
moveVertical(doc, state, key === "j" ? +1 : -1, Math.max(1, count))
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Plain motions.
|
||||
const motion = resolveMotion(doc.text, doc.cursor, key, count, false)
|
||||
if (motion) {
|
||||
state.desiredColumn = undefined
|
||||
doc.setCursor(clampNormal(doc.text, motion.target))
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Unknown key: swallow it so stray characters never leak into the prompt
|
||||
// while in NORMAL mode.
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
function finishSimple(state: VimState, result: VimResult): VimResult {
|
||||
state.countDigits = ""
|
||||
return result
|
||||
}
|
||||
|
||||
function resetPending(state: VimState) {
|
||||
state.operator = undefined
|
||||
state.awaitingG = false
|
||||
state.awaitingReplace = false
|
||||
state.countDigits = ""
|
||||
state.desiredColumn = undefined
|
||||
}
|
||||
|
||||
/** Switch to NORMAL mode, clamping the cursor like vim does on `<Esc>`. */
|
||||
export function enterNormal(doc: VimDoc, state: VimState) {
|
||||
state.mode = "normal"
|
||||
state.visualAnchor = undefined
|
||||
doc.clearSelection()
|
||||
resetPending(state)
|
||||
// On leaving insert mode vim moves the cursor one left (unless at line start).
|
||||
const start = lineStart(doc.text, doc.cursor)
|
||||
if (doc.cursor > start) doc.setCursor(doc.cursor - 1)
|
||||
doc.setCursor(clampNormal(doc.text, doc.cursor))
|
||||
}
|
||||
|
||||
// --- visual mode -----------------------------------------------------------
|
||||
|
||||
function updateVisualSelection(doc: VimDoc, state: VimState) {
|
||||
if (doc.text.length === 0) {
|
||||
doc.clearSelection()
|
||||
return
|
||||
}
|
||||
const anchor = state.visualAnchor ?? doc.cursor
|
||||
const a = Math.min(anchor, doc.cursor)
|
||||
const b = Math.max(anchor, doc.cursor)
|
||||
if (state.mode === "visual-line") {
|
||||
doc.setSelection(lineStart(doc.text, a), lineEnd(doc.text, b))
|
||||
return
|
||||
}
|
||||
doc.setSelection(a, Math.min(doc.text.length, b + 1))
|
||||
}
|
||||
|
||||
/** Leave any visual mode and return to NORMAL, clearing the highlight. */
|
||||
export function exitVisual(doc: VimDoc, state: VimState) {
|
||||
state.mode = "normal"
|
||||
state.visualAnchor = undefined
|
||||
doc.clearSelection()
|
||||
resetPending(state)
|
||||
doc.setCursor(clampNormal(doc.text, doc.cursor))
|
||||
}
|
||||
|
||||
function visualOperate(doc: VimDoc, state: VimState, op: "d" | "c" | "y"): VimResult {
|
||||
const linewise = state.mode === "visual-line"
|
||||
const anchor = state.visualAnchor ?? doc.cursor
|
||||
const a = Math.min(anchor, doc.cursor)
|
||||
const b = Math.max(anchor, doc.cursor)
|
||||
doc.clearSelection()
|
||||
state.visualAnchor = undefined
|
||||
|
||||
let result: VimResult
|
||||
if (linewise) {
|
||||
result = linewiseOperate(doc, state, op, lineStart(doc.text, a), lineEnd(doc.text, b))
|
||||
} else {
|
||||
const start = a
|
||||
const end = Math.min(doc.text.length, b + 1) // charwise visual is inclusive
|
||||
if (start === end) {
|
||||
exitVisual(doc, state)
|
||||
return { handled: true }
|
||||
}
|
||||
state.register = { text: doc.text.slice(start, end), linewise: false }
|
||||
if (op === "y") {
|
||||
doc.setCursor(clampNormal(doc.text, start))
|
||||
result = { handled: true }
|
||||
} else {
|
||||
doc.remove(start, end)
|
||||
if (op === "c") {
|
||||
doc.setCursor(start)
|
||||
state.mode = "insert"
|
||||
result = { handled: true, enteredInsert: true }
|
||||
} else {
|
||||
doc.setCursor(clampNormal(doc.text, start))
|
||||
result = { handled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.enteredInsert) state.mode = "normal"
|
||||
resetPending(state)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a key in VISUAL / VISUAL-LINE mode. Mutates `state` and `doc`. The
|
||||
* caller routes here when `state.mode` is "visual" or "visual-line".
|
||||
*/
|
||||
export function handleVisualKey(doc: VimDoc, state: VimState, input: VimKey): VimResult {
|
||||
const { key } = input
|
||||
|
||||
if (key === "escape") {
|
||||
exitVisual(doc, state)
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Numeric count (a leading 0 is the line-start motion, not a count).
|
||||
if (/[0-9]/.test(key) && !(key === "0" && state.countDigits === "")) {
|
||||
state.countDigits += key
|
||||
return { handled: true }
|
||||
}
|
||||
const count = state.countDigits ? parseInt(state.countDigits, 10) : 0
|
||||
|
||||
// `g` prefix (gg).
|
||||
if (key === "g" && !state.awaitingG) {
|
||||
state.awaitingG = true
|
||||
return { handled: true }
|
||||
}
|
||||
if (state.awaitingG) {
|
||||
const motion = resolveMotion(doc.text, doc.cursor, key, count, true)
|
||||
state.awaitingG = false
|
||||
state.countDigits = ""
|
||||
if (motion) {
|
||||
doc.setCursor(clampNormal(doc.text, motion.target))
|
||||
updateVisualSelection(doc, state)
|
||||
}
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Toggle / switch visual sub-modes.
|
||||
if (key === "v") {
|
||||
if (state.mode === "visual") exitVisual(doc, state)
|
||||
else {
|
||||
state.mode = "visual"
|
||||
updateVisualSelection(doc, state)
|
||||
}
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
if (key === "V") {
|
||||
if (state.mode === "visual-line") exitVisual(doc, state)
|
||||
else {
|
||||
state.mode = "visual-line"
|
||||
updateVisualSelection(doc, state)
|
||||
}
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Swap the moving end with the anchor.
|
||||
if (key === "o") {
|
||||
const anchor = state.visualAnchor ?? doc.cursor
|
||||
state.visualAnchor = doc.cursor
|
||||
doc.setCursor(clampNormal(doc.text, anchor))
|
||||
updateVisualSelection(doc, state)
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Operators act on the selection, then leave visual mode.
|
||||
if (key === "d" || key === "x") return finishSimple(state, visualOperate(doc, state, "d"))
|
||||
if (key === "c" || key === "s") return finishSimple(state, visualOperate(doc, state, "c"))
|
||||
if (key === "y") return finishSimple(state, visualOperate(doc, state, "y"))
|
||||
|
||||
// Motions extend the selection.
|
||||
if (key === "j" || key === "k") {
|
||||
moveVertical(doc, state, key === "j" ? +1 : -1, Math.max(1, count))
|
||||
updateVisualSelection(doc, state)
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
const motion = resolveMotion(doc.text, doc.cursor, key, count, false)
|
||||
if (motion) {
|
||||
state.desiredColumn = undefined
|
||||
doc.setCursor(clampNormal(doc.text, motion.target))
|
||||
updateVisualSelection(doc, state)
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
// Unknown key: swallow so nothing leaks into the prompt.
|
||||
state.countDigits = ""
|
||||
return { handled: true }
|
||||
}
|
||||
|
||||
export function enterInsert(state: VimState) {
|
||||
state.mode = "insert"
|
||||
resetPending(state)
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createVimState,
|
||||
enterNormal,
|
||||
handleNormalKey,
|
||||
handleVisualKey,
|
||||
lineEnd,
|
||||
lineStart,
|
||||
clampNormal,
|
||||
type VimDoc,
|
||||
type VimKey,
|
||||
} from "@/kilocode/cli/cmd/tui/component/prompt/vim"
|
||||
|
||||
/** In-memory document implementing the engine's VimDoc contract. */
|
||||
class MockDoc implements VimDoc {
|
||||
private _text: string
|
||||
private _cursor: number
|
||||
private history: { text: string; cursor: number }[] = []
|
||||
private future: { text: string; cursor: number }[] = []
|
||||
selection: { start: number; end: number } | null = null
|
||||
|
||||
constructor(text = "", cursor = 0) {
|
||||
this._text = text
|
||||
this._cursor = cursor
|
||||
}
|
||||
|
||||
setSelection(start: number, end: number) {
|
||||
this.selection = { start, end }
|
||||
}
|
||||
clearSelection() {
|
||||
this.selection = null
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this._text
|
||||
}
|
||||
get cursor() {
|
||||
return this._cursor
|
||||
}
|
||||
setCursor(offset: number) {
|
||||
this._cursor = Math.max(0, Math.min(offset, this._text.length))
|
||||
}
|
||||
private snapshot() {
|
||||
this.history.push({ text: this._text, cursor: this._cursor })
|
||||
this.future = []
|
||||
}
|
||||
insert(offset: number, value: string) {
|
||||
this.snapshot()
|
||||
this._text = this._text.slice(0, offset) + value + this._text.slice(offset)
|
||||
this._cursor = offset + value.length
|
||||
}
|
||||
remove(start: number, end: number) {
|
||||
this.snapshot()
|
||||
const removed = this._text.slice(start, end)
|
||||
this._text = this._text.slice(0, start) + this._text.slice(end)
|
||||
this._cursor = start
|
||||
return removed
|
||||
}
|
||||
undo() {
|
||||
const prev = this.history.pop()
|
||||
if (!prev) return
|
||||
this.future.push({ text: this._text, cursor: this._cursor })
|
||||
this._text = prev.text
|
||||
this._cursor = prev.cursor
|
||||
}
|
||||
redo() {
|
||||
const next = this.future.pop()
|
||||
if (!next) return
|
||||
this.history.push({ text: this._text, cursor: this._cursor })
|
||||
this._text = next.text
|
||||
this._cursor = next.cursor
|
||||
}
|
||||
}
|
||||
|
||||
/** Feed a sequence of single-char keys (with optional ctrl via "<C-x>"). */
|
||||
function feed(doc: MockDoc, state: ReturnType<typeof createVimState>, keys: string) {
|
||||
let i = 0
|
||||
while (i < keys.length) {
|
||||
let vk: VimKey
|
||||
if (keys.startsWith("<C-", i)) {
|
||||
const close = keys.indexOf(">", i)
|
||||
vk = { key: keys.slice(i + 3, close), ctrl: true }
|
||||
i = close + 1
|
||||
} else if (keys.startsWith("<esc>", i)) {
|
||||
vk = { key: "escape" }
|
||||
i += 5
|
||||
} else {
|
||||
vk = { key: keys[i]! }
|
||||
i += 1
|
||||
}
|
||||
if (state.mode === "visual" || state.mode === "visual-line") handleVisualKey(doc, state, vk)
|
||||
else handleNormalKey(doc, state, vk)
|
||||
}
|
||||
}
|
||||
|
||||
describe("vim line helpers", () => {
|
||||
test("lineStart / lineEnd", () => {
|
||||
const t = "abc\ndef\nghi"
|
||||
expect(lineStart(t, 5)).toBe(4)
|
||||
expect(lineEnd(t, 5)).toBe(7)
|
||||
expect(lineStart(t, 0)).toBe(0)
|
||||
expect(lineEnd(t, 0)).toBe(3)
|
||||
})
|
||||
|
||||
test("clampNormal keeps cursor off the trailing newline", () => {
|
||||
const t = "abc\ndef"
|
||||
expect(clampNormal(t, 3)).toBe(2)
|
||||
expect(clampNormal(t, 7)).toBe(6)
|
||||
expect(clampNormal("", 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("vim motions", () => {
|
||||
test("h and l move within the line and clamp", () => {
|
||||
const doc = new MockDoc("hello", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "lll")
|
||||
expect(doc.cursor).toBe(3)
|
||||
feed(doc, state, "h")
|
||||
expect(doc.cursor).toBe(2)
|
||||
feed(doc, state, "hhhhh")
|
||||
expect(doc.cursor).toBe(0)
|
||||
})
|
||||
|
||||
test("counts repeat motions (3l)", () => {
|
||||
const doc = new MockDoc("hello world", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "3l")
|
||||
expect(doc.cursor).toBe(3)
|
||||
})
|
||||
|
||||
test("w / b word motions", () => {
|
||||
const doc = new MockDoc("foo bar baz", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "w")
|
||||
expect(doc.cursor).toBe(4)
|
||||
feed(doc, state, "w")
|
||||
expect(doc.cursor).toBe(8)
|
||||
feed(doc, state, "b")
|
||||
expect(doc.cursor).toBe(4)
|
||||
})
|
||||
|
||||
test("e moves to end of word", () => {
|
||||
const doc = new MockDoc("foo bar", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "e")
|
||||
expect(doc.cursor).toBe(2)
|
||||
})
|
||||
|
||||
test("0 and $ jump to line bounds", () => {
|
||||
const doc = new MockDoc("hello world", 3)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "$")
|
||||
expect(doc.cursor).toBe(10)
|
||||
feed(doc, state, "0")
|
||||
expect(doc.cursor).toBe(0)
|
||||
})
|
||||
|
||||
test("gg and G jump across lines", () => {
|
||||
const doc = new MockDoc("one\ntwo\nthree", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "G")
|
||||
expect(lineStart(doc.text, doc.cursor)).toBe(8)
|
||||
feed(doc, state, "gg")
|
||||
expect(doc.cursor).toBe(0)
|
||||
})
|
||||
|
||||
test("j and k preserve column", () => {
|
||||
const doc = new MockDoc("abcd\nef\nghij", 2)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "j")
|
||||
// second line "ef" is shorter; clamp to last char
|
||||
expect(doc.cursor).toBe(6)
|
||||
feed(doc, state, "j")
|
||||
expect(doc.cursor).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("vim edits", () => {
|
||||
test("x deletes the char under the cursor", () => {
|
||||
const doc = new MockDoc("hello", 1)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "x")
|
||||
expect(doc.text).toBe("hllo")
|
||||
expect(doc.cursor).toBe(1)
|
||||
})
|
||||
|
||||
test("2x deletes two chars", () => {
|
||||
const doc = new MockDoc("hello", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "2x")
|
||||
expect(doc.text).toBe("llo")
|
||||
})
|
||||
|
||||
test("dw deletes a word", () => {
|
||||
const doc = new MockDoc("foo bar baz", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "dw")
|
||||
expect(doc.text).toBe("bar baz")
|
||||
})
|
||||
|
||||
test("dd deletes the current line", () => {
|
||||
const doc = new MockDoc("one\ntwo\nthree", 4)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "dd")
|
||||
expect(doc.text).toBe("one\nthree")
|
||||
})
|
||||
|
||||
test("D deletes to end of line", () => {
|
||||
const doc = new MockDoc("hello world", 5)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "D")
|
||||
expect(doc.text).toBe("hello")
|
||||
})
|
||||
|
||||
test("cw deletes word and enters insert mode", () => {
|
||||
const doc = new MockDoc("foo bar", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "cw")
|
||||
expect(doc.text).toBe(" bar")
|
||||
expect(state.mode).toBe("insert")
|
||||
})
|
||||
|
||||
test("r replaces a single char", () => {
|
||||
const doc = new MockDoc("cat", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "rb")
|
||||
expect(doc.text).toBe("bat")
|
||||
expect(state.mode).toBe("normal")
|
||||
})
|
||||
|
||||
test("u undoes and <C-r> redoes", () => {
|
||||
const doc = new MockDoc("hello", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "x")
|
||||
expect(doc.text).toBe("ello")
|
||||
feed(doc, state, "u")
|
||||
expect(doc.text).toBe("hello")
|
||||
feed(doc, state, "<C-r>")
|
||||
expect(doc.text).toBe("ello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("vim linewise EOF edge cases", () => {
|
||||
test("dd on the final line keeps the preceding line", () => {
|
||||
const doc = new MockDoc("a\nb\nc", 4)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "dd")
|
||||
expect(doc.text).toBe("a\nb")
|
||||
})
|
||||
|
||||
test("dd on the only line empties the buffer", () => {
|
||||
const doc = new MockDoc("abc", 1)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "dd")
|
||||
expect(doc.text).toBe("")
|
||||
})
|
||||
|
||||
test("dG deletes through EOF without dropping an extra line", () => {
|
||||
const doc = new MockDoc("a\nb\nc", 2)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "dG")
|
||||
expect(doc.text).toBe("a")
|
||||
})
|
||||
|
||||
test("cc on the final line leaves one empty line to type on", () => {
|
||||
const doc = new MockDoc("a\nb\nc", 4)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "cc")
|
||||
expect(doc.text).toBe("a\nb\n")
|
||||
expect(state.mode).toBe("insert")
|
||||
})
|
||||
|
||||
test("cc on a middle line clears it but keeps the line", () => {
|
||||
const doc = new MockDoc("a\nb\nc", 2)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "cc")
|
||||
expect(doc.text).toBe("a\n\nc")
|
||||
})
|
||||
|
||||
test("yy + p after the final line keeps a separating newline", () => {
|
||||
const doc = new MockDoc("a\nb\nc", 4)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "yyp")
|
||||
expect(doc.text).toBe("a\nb\nc\nc")
|
||||
})
|
||||
|
||||
test("V + d through EOF keeps the preceding line", () => {
|
||||
const doc = new MockDoc("a\nb\nc", 4)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "Vd")
|
||||
expect(doc.text).toBe("a\nb")
|
||||
})
|
||||
})
|
||||
|
||||
describe("vim yank/paste", () => {
|
||||
test("yy then p duplicates the line below", () => {
|
||||
const doc = new MockDoc("one\ntwo", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "yyp")
|
||||
expect(doc.text).toBe("one\none\ntwo")
|
||||
})
|
||||
|
||||
test("dw then p pastes the deleted word", () => {
|
||||
const doc = new MockDoc("foo bar", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "dw") // text => "bar", register "foo "
|
||||
feed(doc, state, "p")
|
||||
expect(doc.text).toContain("foo")
|
||||
})
|
||||
})
|
||||
|
||||
describe("vim visual mode", () => {
|
||||
test("v enters visual and selects the char under the cursor", () => {
|
||||
const doc = new MockDoc("hello", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "v")
|
||||
expect(state.mode).toBe("visual")
|
||||
expect(doc.selection).toEqual({ start: 0, end: 1 })
|
||||
})
|
||||
|
||||
test("v + motion extends the selection", () => {
|
||||
const doc = new MockDoc("hello world", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "v")
|
||||
feed(doc, state, "e") // select "hello"
|
||||
expect(doc.selection).toEqual({ start: 0, end: 5 })
|
||||
})
|
||||
|
||||
test("vw d deletes the selection", () => {
|
||||
const doc = new MockDoc("foo bar", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "v")
|
||||
feed(doc, state, "e") // select "foo"
|
||||
feed(doc, state, "d")
|
||||
expect(doc.text).toBe(" bar")
|
||||
expect(state.mode).toBe("normal")
|
||||
expect(doc.selection).toBeNull()
|
||||
})
|
||||
|
||||
test("visual y yanks selection; p pastes it", () => {
|
||||
const doc = new MockDoc("foo bar", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "v")
|
||||
feed(doc, state, "e") // select "foo"
|
||||
feed(doc, state, "y")
|
||||
expect(state.mode).toBe("normal")
|
||||
expect(state.register.text).toBe("foo")
|
||||
feed(doc, state, "p")
|
||||
expect(doc.text).toContain("foo")
|
||||
})
|
||||
|
||||
test("visual c deletes selection and enters insert", () => {
|
||||
const doc = new MockDoc("foo bar", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "v")
|
||||
feed(doc, state, "e")
|
||||
feed(doc, state, "c")
|
||||
expect(doc.text).toBe(" bar")
|
||||
expect(state.mode).toBe("insert")
|
||||
})
|
||||
|
||||
test("V selects whole lines and d deletes them", () => {
|
||||
const doc = new MockDoc("one\ntwo\nthree", 4)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "V") // line "two"
|
||||
expect(state.mode).toBe("visual-line")
|
||||
feed(doc, state, "d")
|
||||
expect(doc.text).toBe("one\nthree")
|
||||
expect(state.mode).toBe("normal")
|
||||
})
|
||||
|
||||
test("V + j extends across lines", () => {
|
||||
const doc = new MockDoc("one\ntwo\nthree", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "V")
|
||||
feed(doc, state, "j") // lines one+two
|
||||
feed(doc, state, "d")
|
||||
expect(doc.text).toBe("three")
|
||||
})
|
||||
|
||||
test("escape leaves visual mode and clears selection", () => {
|
||||
const doc = new MockDoc("hello", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "v")
|
||||
feed(doc, state, "ll")
|
||||
feed(doc, state, "<esc>")
|
||||
expect(state.mode).toBe("normal")
|
||||
expect(doc.selection).toBeNull()
|
||||
})
|
||||
|
||||
test("v again toggles visual mode off", () => {
|
||||
const doc = new MockDoc("hello", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "v")
|
||||
feed(doc, state, "v")
|
||||
expect(state.mode).toBe("normal")
|
||||
expect(doc.selection).toBeNull()
|
||||
})
|
||||
|
||||
test("o swaps the selection ends", () => {
|
||||
const doc = new MockDoc("hello world", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "v")
|
||||
feed(doc, state, "ll") // anchor 0, cursor 2
|
||||
feed(doc, state, "o") // swap: anchor 2, cursor 0
|
||||
expect(doc.cursor).toBe(0)
|
||||
expect(state.visualAnchor).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("vim mode transitions", () => {
|
||||
test("i enters insert mode", () => {
|
||||
const doc = new MockDoc("abc", 1)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "i")
|
||||
expect(state.mode).toBe("insert")
|
||||
})
|
||||
|
||||
test("a appends after cursor", () => {
|
||||
const doc = new MockDoc("abc", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "a")
|
||||
expect(doc.cursor).toBe(1)
|
||||
expect(state.mode).toBe("insert")
|
||||
})
|
||||
|
||||
test("A jumps to end of line", () => {
|
||||
const doc = new MockDoc("abc", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "A")
|
||||
expect(doc.cursor).toBe(3)
|
||||
expect(state.mode).toBe("insert")
|
||||
})
|
||||
|
||||
test("o opens a line below", () => {
|
||||
const doc = new MockDoc("abc", 1)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "o")
|
||||
expect(doc.text).toBe("abc\n")
|
||||
expect(doc.cursor).toBe(4)
|
||||
})
|
||||
|
||||
test("O opens a line above", () => {
|
||||
const doc = new MockDoc("abc", 1)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "O")
|
||||
expect(doc.text).toBe("\nabc")
|
||||
expect(doc.cursor).toBe(0)
|
||||
})
|
||||
|
||||
test("enterNormal clamps cursor one left", () => {
|
||||
const doc = new MockDoc("abc", 3)
|
||||
const state = createVimState("insert")
|
||||
enterNormal(doc, state)
|
||||
expect(state.mode).toBe("normal")
|
||||
expect(doc.cursor).toBe(2)
|
||||
})
|
||||
|
||||
test("escape clears a pending operator", () => {
|
||||
const doc = new MockDoc("foo bar", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "d")
|
||||
expect(state.operator).toBe("d")
|
||||
feed(doc, state, "<esc>")
|
||||
expect(state.operator).toBeUndefined()
|
||||
expect(doc.text).toBe("foo bar")
|
||||
})
|
||||
|
||||
test("normal-mode letters never leak into the document", () => {
|
||||
const doc = new MockDoc("abc", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "zZqQ")
|
||||
expect(doc.text).toBe("abc")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user