Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 common 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), and insert transitions (i/a/A/I/o/O), with a NORMAL/INSERT indicator and matching cursor shape.
149 changes: 148 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,15 @@ 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,
type VimDoc,
type VimKey,
} from "./vim"
// kilocode_change end

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -205,6 +214,89 @@ 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">("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
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()
},
}
}
/**
* 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
}

// NORMAL mode. Keep Enter (submit) and Tab (autocomplete) working rather
// than emulating strict vim line motions for them.
if (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 = 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 +424,19 @@ 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()
return
}
cursorVersion()
if (!input || input.isDestroyed) return
// Block cursor in NORMAL mode, bar cursor in INSERT mode (vim convention).
input.cursorStyle = vimMode() === "normal" ? { style: "block", blinking: false } : { style: "line", blinking: true }
})
// kilocode_change end

const lastUserMessage = createMemo(() => {
if (!props.sessionID) return undefined
const messages = sync.data.message[props.sessionID]
Expand Down Expand Up @@ -587,6 +692,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 +767,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 +800,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 @@ -1261,6 +1384,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 @@ -1548,11 +1672,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 +1745,23 @@ 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() === "normal" ? theme.success : theme.info, agentMetaAlpha()),
bold: true,
}}
>
{vimMode() === "normal" ? "NORMAL" : "INSERT"}
</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