refactor(cli): extract vim prompt logic into kilocode mirror

Move the vim modal editing engine and prompt integration out of shared
upstream files and into src/kilocode/ mirrors per the Fork Isolation Rule:

- src/kilocode/cli/cmd/tui/component/prompt/vim.ts (engine, moved)
- src/kilocode/cli/cmd/tui/component/prompt/index.tsx (new mirror with
  useVim, VimModeIndicator, vimToggleCommand)
- test/kilocode/cli/cmd/tui/prompt/vim.test.ts (moved)

The shared prompt/index.tsx now calls into the mirror behind minimal
kilocode_change markers instead of inlining ~200 lines of vim logic.
This commit is contained in:
Catriel Müller
2026-07-03 17:52:17 -03:00
parent a5df31034d
commit 2be834fbfc
6 changed files with 268 additions and 173 deletions
@@ -67,14 +67,7 @@ import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
import { useTuiConfig } from "../../context/tui-config"
// kilocode_change start - vim modal editing for the prompt
import {
createVimState,
enterNormal as vimEnterNormal,
handleNormalKey as vimHandleNormalKey,
handleVisualKey as vimHandleVisualKey,
type VimDoc,
type VimKey,
} from "./vim"
import { useVim, VimModeIndicator, vimToggleCommand } from "@/kilocode/cli/cmd/tui/component/prompt"
// kilocode_change end
export type PromptProps = {
@@ -216,98 +209,14 @@ export function Prompt(props: PromptProps) {
const [warpNotice, setWarpNotice] = createSignal<string>()
const [cursorVersion, setCursorVersion] = createSignal(0)
// kilocode_change start - vim modal editing for the prompt
const vimState = createVimState("insert")
const [vimMode, setVimMode] = createSignal<"insert" | "normal" | "visual" | "visual-line">("insert")
const vimEnabled = createMemo(() => kv.get("vim_enabled", tuiConfig.vim ?? false))
function syncVimMode() {
if (vimState.mode !== vimMode()) setVimMode(vimState.mode)
setCursorVersion((value) => value + 1)
}
function resetVim() {
vimState.mode = "insert"
vimState.operator = undefined
vimState.awaitingG = false
vimState.awaitingReplace = false
vimState.countDigits = ""
vimState.desiredColumn = undefined
vimState.visualAnchor = undefined
if (input && !input.isDestroyed) input.clearSelection()
setVimMode("insert")
}
function vimDoc(): 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 {
if (!vimEnabled() || props.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 (vimState.mode === "insert") {
if (e.name === "escape" && !auto()?.visible) {
vimEnterNormal(vimDoc(), vimState)
syncVimMode()
return true
}
return false
}
const visual = vimState.mode === "visual" || vimState.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 ? vimHandleVisualKey(vimDoc(), vimState, key) : vimHandleNormalKey(vimDoc(), vimState, key)
if (result.handled) syncVimMode()
return result.handled
}
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))
@@ -436,22 +345,6 @@ export function Prompt(props: PromptProps) {
if (!props.disabled) input.cursorColor = theme.text
})
// kilocode_change start - vim cursor shape + reset when vim is disabled
createEffect(() => {
if (!vimEnabled()) {
if (vimState.mode !== "insert") resetVim()
// Restore the default (non-vim) cursor so a block cursor from NORMAL/VISUAL
// mode does not linger after vim mode is turned off.
if (input && !input.isDestroyed) input.cursorStyle = { style: "block", blinking: true }
return
}
cursorVersion()
if (!input || input.isDestroyed) return
// Block cursor in NORMAL/VISUAL modes, bar cursor in INSERT mode (vim convention).
input.cursorStyle = vimMode() === "insert" ? { style: "line", blinking: true } : { style: "block", blinking: false }
})
// kilocode_change end
const lastUserMessage = createMemo(() => {
if (!props.sessionID) return undefined
const messages = sync.data.message[props.sessionID]
@@ -708,20 +601,13 @@ export function Prompt(props: PromptProps) {
},
},
// kilocode_change start - vim modal editing toggle (palette + /vim)
{
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 = !vimEnabled()
kv.set("vim_enabled", next)
resetVim()
dialog.clear()
toast.show({ message: next ? "Vim mode enabled" : "Vim mode disabled", variant: "info" })
},
},
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",
@@ -815,7 +701,7 @@ export function Prompt(props: PromptProps) {
parts: [],
})
setStore("extmarkToPartIndex", new Map())
resetVim() // kilocode_change - return to insert mode after the prompt is cleared
vim.resetVim() // kilocode_change - return to insert mode after the prompt is cleared
},
submit() {
void submit()
@@ -968,7 +854,7 @@ export function Prompt(props: PromptProps) {
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
resetVim() // kilocode_change
vim.resetVim() // kilocode_change
dialog.clear()
},
},
@@ -984,7 +870,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
resetVim() // kilocode_change
vim.resetVim() // kilocode_change
}
dialog.clear()
},
@@ -1002,7 +888,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
resetVim() // kilocode_change
vim.resetVim() // kilocode_change
}}
/>
))
@@ -1125,7 +1011,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
resetVim() // kilocode_change - recalled history starts in insert mode
vim.resetVim() // kilocode_change - recalled history starts in insert mode
input.cursorOffset = 0
},
},
@@ -1162,7 +1048,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
resetVim() // kilocode_change - recalled history starts in insert mode
vim.resetVim() // kilocode_change - recalled history starts in insert mode
input.cursorOffset = input.plainText.length
},
},
@@ -1404,7 +1290,7 @@ export function Prompt(props: PromptProps) {
}, 50)
}
input.clear()
resetVim() // kilocode_change - drop back to insert mode after sending
vim.resetVim() // kilocode_change - drop back to insert mode after sending
return true
}
const exit = useExit()
@@ -1565,7 +1451,7 @@ export function Prompt(props: PromptProps) {
parts: [],
})
setStore("extmarkToPartIndex", new Map())
resetVim() // kilocode_change - don't leak stale vim mode/selection into an emptied prompt
vim.resetVim() // kilocode_change - don't leak stale vim mode/selection into an emptied prompt
}
const highlight = createMemo(() => {
@@ -1693,17 +1579,18 @@ export function Prompt(props: PromptProps) {
setCursorVersion((value) => value + 1)
if (store.mode === "normal") auto()?.onCursorChange()
}}
onKeyDown={(e: KeyEvent) => {
/* kilocode_change - KeyEvent type for vim key routing */ onKeyDown={(e: KeyEvent) => {
if (props.disabled) {
e.preventDefault()
return
}
// kilocode_change - route keys through the vim layer when enabled
if (vimOnKey(e)) {
// 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
@@ -1767,34 +1654,16 @@ export function Prompt(props: PromptProps) {
{/* kilocode_change end */}
</text>
{/* kilocode_change start - vim mode indicator */}
<Show when={vimEnabled() && store.mode !== "shell"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.textMuted, agentMetaAlpha())}>·</text>
<text>
<span
style={{
fg: fadeColor(
vimMode() === "insert"
? theme.info
: vimMode() === "visual" || vimMode() === "visual-line"
? theme.warning
: theme.success,
agentMetaAlpha(),
),
bold: true,
}}
>
{vimMode() === "insert"
? "INSERT"
: vimMode() === "visual"
? "VISUAL"
: vimMode() === "visual-line"
? "V-LINE"
: "NORMAL"}
</span>
</text>
</box>
</Show>
<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}>
@@ -199,7 +199,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"),
"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"),
@@ -77,7 +77,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")
},
}
}
@@ -1,4 +1,3 @@
// kilocode_change start - vim modal editing for the prompt input
/**
* A small, self-contained vim emulation engine for the prompt textarea.
*
@@ -883,4 +882,3 @@ export function enterInsert(state: VimState) {
state.mode = "insert"
resetPending(state)
}
// kilocode_change end
@@ -9,7 +9,7 @@ import {
clampNormal,
type VimDoc,
type VimKey,
} from "@tui/component/prompt/vim"
} from "@/kilocode/cli/cmd/tui/component/prompt/vim"
/** In-memory document implementing the engine's VimDoc contract. */
class MockDoc implements VimDoc {