Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vim-mode-prompt-input.md
Original file line number Diff line number Diff line change
@@ -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.
183 changes: 182 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ import { type WorkspaceStatus } from "../workspace-label"
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"
// kilocode_change end

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -205,6 +215,100 @@ 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 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
}
// kilocode_change end
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
Expand Down Expand Up @@ -332,6 +436,22 @@ 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()) {
Comment thread
drye marked this conversation as resolved.
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]
Expand Down Expand Up @@ -587,6 +707,22 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = Bun.stringWidth(content)
},
},
// 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" })
},
},
// kilocode_change end
{
title: "Skills",
name: "prompt.skills",
Expand Down Expand Up @@ -646,6 +782,7 @@ export function Prompt(props: PromptProps) {
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"prompt.vim.toggle", // kilocode_change
"session.interrupt",
"workspace.set",
]),
Expand Down Expand Up @@ -678,6 +815,7 @@ export function Prompt(props: PromptProps) {
parts: [],
})
setStore("extmarkToPartIndex", new Map())
resetVim() // kilocode_change - return to insert mode after the prompt is cleared
},
submit() {
void submit()
Expand Down Expand Up @@ -830,6 +968,7 @@ export function Prompt(props: PromptProps) {
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
resetVim() // kilocode_change
dialog.clear()
},
},
Expand All @@ -845,6 +984,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
resetVim() // kilocode_change
}
dialog.clear()
},
Expand All @@ -862,6 +1002,7 @@ export function Prompt(props: PromptProps) {
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
resetVim() // kilocode_change
}}
/>
))
Expand Down Expand Up @@ -984,6 +1125,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
input.cursorOffset = 0
},
},
Expand Down Expand Up @@ -1020,6 +1162,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
input.cursorOffset = input.plainText.length
},
},
Expand Down Expand Up @@ -1261,6 +1404,7 @@ export function Prompt(props: PromptProps) {
}, 50)
}
input.clear()
resetVim() // kilocode_change - drop back to insert mode after sending
return true
}
const exit = useExit()
Expand Down Expand Up @@ -1421,6 +1565,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
}

const highlight = createMemo(() => {
Expand Down Expand Up @@ -1548,11 +1693,17 @@ export function Prompt(props: PromptProps) {
setCursorVersion((value) => value + 1)
if (store.mode === "normal") auto()?.onCursorChange()
}}
onKeyDown={(e: { preventDefault(): void }) => {
onKeyDown={(e: KeyEvent) => {
if (props.disabled) {
e.preventDefault()
return
}
// kilocode_change - route keys through the vim layer when enabled
if (vimOnKey(e)) {
e.preventDefault()
e.stopPropagation()
return
}
}}
onSubmit={() => {
// IME: double-defer so the last composed character (e.g. Korean
Expand Down Expand Up @@ -1615,6 +1766,36 @@ export function Prompt(props: PromptProps) {
Locale.titlecase(local.agent.current()?.name ?? ""))}{" "}
{/* 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>
{/* kilocode_change end */}
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
Expand Down
Loading