From b2244b703f37fe52ee5887d537d0e14da167fe53 Mon Sep 17 00:00:00 2001 From: Dorian Moy Date: Tue, 28 Jul 2026 14:12:52 +0200 Subject: [PATCH 1/5] feat: add :w and :write commands that send the prompt Previously typing :w in the command palette autocompleted to :wq and quit OpenCode. Vim users hit :w reflexively to save, so map it to the closest equivalent here: dispatch input.submit. :write is the long form. The :wq quit command is unaffected. --- src/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/index.ts b/src/index.ts index 7fe99d4..7ded6f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -263,9 +263,25 @@ const plugin: TuiPluginModule = { slashName: cmd, run: exitRun, })); + // `:w` is the muscle-memory "save" for vim users. Sessions auto-persist, + // so the natural mapping is to send the prompt instead of (as before) + // autocompleting to `:wq` and quitting OpenCode. + const submitRun = async () => { + setTimeout(() => api.keymap.dispatchCommand("input.submit"), 0); + }; + const submitCommands = ["w", "write"].map((cmd) => ({ + name: `vimcode.${cmd}`, + title: `:${cmd}`, + category: "Vim", + namespace: "palette", + desc: "Send prompt", + slashName: cmd, + run: submitRun, + })); api.keymap.registerLayer?.({ commands: [ ...exitCommands, + ...submitCommands, { name: "vimcode.vim", title: ":vim", From c76966b78710b89ff15d7851b398501401509ada Mon Sep 17 00:00:00 2001 From: Dorian Moy Date: Tue, 28 Jul 2026 14:13:02 +0200 Subject: [PATCH 2/5] docs: document :w and :write in README keybinding table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 99c76b7..104c6af 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b | `Ctrl+r` | Redo | | `p` | Paste from yank register | | `:` | Command palette | +| `:w` `:write` | Send prompt (via command palette) | | `:q` `:quit` `:wq` | Quit OpenCode (via command palette) | | `:vim` | Toggle vim mode on/off (persisted across restarts) | | `/` | Jump to message (session timeline) | From 0b3cdcffa2cef4c7ac1725b009015e65c2663028 Mon Sep 17 00:00:00 2001 From: Dorian Moy Date: Tue, 28 Jul 2026 14:13:13 +0200 Subject: [PATCH 3/5] docs: add :w/:write changelog entry under [Unreleased] --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bed1dd5..cee1a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### Added + +- `:w` and `:write` now send the prompt instead of autocompleting to `:wq` and quitting OpenCode. Sessions auto-persist, so the natural mapping for vim users' reflexive "save" is to submit the current prompt. `:wq` still quits. + ## [0.15.3] — 2026-07-01 ### Fixed From b245fea2ac411abf74541faf5398de420f08101b Mon Sep 17 00:00:00 2001 From: Dorian Moy Date: Tue, 28 Jul 2026 15:07:35 +0200 Subject: [PATCH 4/5] docs(write-motion): Removed useless comments --- src/index.ts | 741 +++++++++++++++++++++++++-------------------------- 1 file changed, 369 insertions(+), 372 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7ded6f0..e190009 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,409 +3,406 @@ import { writeClipboard } from "./clipboard"; import { findMatchingLeader, type KeyLike, leaderChar } from "./leader"; import { checkForUpdate } from "./version"; import { - type Action, - createVimState, - finishOneShotIfComplete, - handleInsertKey, - handleNormalKey, - handleVisualKey, - toggleVimMode, - translateKey, + type Action, + createVimState, + finishOneShotIfComplete, + handleInsertKey, + handleNormalKey, + handleVisualKey, + toggleVimMode, + translateKey, } from "./vim"; const plugin: TuiPluginModule = { - id: "vimcode", - tui: async (api, options) => { - const state = createVimState(); - const startMode = options?.startMode === "normal" ? "normal" : "insert"; - state.mode = startMode; - const leaderKeys = resolveLeaderKeys(); + id: "vimcode", + tui: async (api, options) => { + const state = createVimState(); + const startMode = options?.startMode === "normal" ? "normal" : "insert"; + state.mode = startMode; + const leaderKeys = resolveLeaderKeys(); - // Resolve modeIndicator: "toast" (default) or "none". - // Backward compat: modeToast:false maps to "none", but only if - // modeIndicator isn't explicitly set. - const modeIndicator: "toast" | "none" = - options?.modeIndicator === "toast" || options?.modeIndicator === "none" - ? options.modeIndicator - : options?.modeToast === false - ? "none" - : "toast"; + // Resolve modeIndicator: "toast" (default) or "none". + // Backward compat: modeToast:false maps to "none", but only if + // modeIndicator isn't explicitly set. + const modeIndicator: "toast" | "none" = + options?.modeIndicator === "toast" || options?.modeIndicator === "none" + ? options.modeIndicator + : options?.modeToast === false + ? "none" + : "toast"; - // Load persisted disabled state - const persistedDisabled = (await api.kv?.get?.("vimcode.disabled")) as boolean | undefined; - state.disabled = persistedDisabled ?? false; - if (state.disabled) { - api.ui?.toast?.({ message: "Vim mode disabled (use /vim to re-enable)", variant: "info", duration: 3000 }); - } + // Load persisted disabled state + const persistedDisabled = (await api.kv?.get?.("vimcode.disabled")) as boolean | undefined; + state.disabled = persistedDisabled ?? false; + if (state.disabled) { + api.ui?.toast?.({ message: "Vim mode disabled (use /vim to re-enable)", variant: "info", duration: 3000 }); + } - // Track whether the previous key was the leader, so the follow-up - // key also passes through to OpenCode's leader system. - let leaderPending = false; - let leaderTimer: ReturnType | null = null; + // Track whether the previous key was the leader, so the follow-up + // key also passes through to OpenCode's leader system. + let leaderPending = false; + let leaderTimer: ReturnType | null = null; - // Track pending permissions/questions from child sessions via events. - // permission()/question() only covers one session ID, but subagent - // prompts live on child IDs. Events fire globally; we aggregate by root. - const pendingChildPrompts = new Map(); + // Track pending permissions/questions from child sessions via events. + // permission()/question() only covers one session ID, but subagent + // prompts live on child IDs. Events fire globally; we aggregate by root. + const pendingChildPrompts = new Map(); - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - function trackPromptEvent(event: any, delta: number) { - const sessionID = event?.properties?.sessionID ?? event?.sessionID; - if (!sessionID) return; - const session = api.state?.session?.get?.(sessionID); - const rootId = session?.parentID ?? sessionID; - const count = (pendingChildPrompts.get(rootId) ?? 0) + delta; - if (count <= 0) pendingChildPrompts.delete(rootId); - else pendingChildPrompts.set(rootId, count); - } + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + function trackPromptEvent(event: any, delta: number) { + const sessionID = event?.properties?.sessionID ?? event?.sessionID; + if (!sessionID) return; + const session = api.state?.session?.get?.(sessionID); + const rootId = session?.parentID ?? sessionID; + const count = (pendingChildPrompts.get(rootId) ?? 0) + delta; + if (count <= 0) pendingChildPrompts.delete(rootId); + else pendingChildPrompts.set(rootId, count); + } - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubPermsAsked = api.event?.on?.("permission.asked", (e: any) => trackPromptEvent(e, 1)); - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubPermsReplied = api.event?.on?.("permission.replied", (e: any) => trackPromptEvent(e, -1)); - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestAsked = api.event?.on?.("question.asked", (e: any) => trackPromptEvent(e, 1)); - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestReplied = api.event?.on?.("question.replied", (e: any) => trackPromptEvent(e, -1)); - api.lifecycle?.onDispose?.(() => { - unsubPermsAsked?.(); - unsubPermsReplied?.(); - unsubQuestAsked?.(); - unsubQuestReplied?.(); - }); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubPermsAsked = api.event?.on?.("permission.asked", (e: any) => trackPromptEvent(e, 1)); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubPermsReplied = api.event?.on?.("permission.replied", (e: any) => trackPromptEvent(e, -1)); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubQuestAsked = api.event?.on?.("question.asked", (e: any) => trackPromptEvent(e, 1)); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubQuestReplied = api.event?.on?.("question.replied", (e: any) => trackPromptEvent(e, -1)); + api.lifecycle?.onDispose?.(() => { + unsubPermsAsked?.(); + unsubPermsReplied?.(); + unsubQuestAsked?.(); + unsubQuestReplied?.(); + }); - function hasActivePrompts(sid: string): boolean { - const q = api.state.session.question(sid); - if (q && q.length > 0) return true; - const p = api.state.session.permission(sid); - if (p && p.length > 0) return true; - return (pendingChildPrompts.get(sid) ?? 0) > 0; - } + function hasActivePrompts(sid: string): boolean { + const q = api.state.session.question(sid); + if (q && q.length > 0) return true; + const p = api.state.session.permission(sid); + if (p && p.length > 0) return true; + return (pendingChildPrompts.get(sid) ?? 0) > 0; + } - // Snapshots for single-step undo of vim changes. - // The host editor's undo system splits repeated commands into multiple - // entries, so we save/restore the buffer ourselves. - let undoSnapshots: Array<{ text: string; cursor: number }> = []; + // Snapshots for single-step undo of vim changes. + // The host editor's undo system splits repeated commands into multiple + // entries, so we save/restore the buffer ourselves. + let undoSnapshots: Array<{ text: string; cursor: number }> = []; - const prompt = { - getLine: (n: number) => getInputText().split("\n")[n] ?? "", - getLineCount: () => getInputText().split("\n").length, - getCursorLine: () => api.renderer?.currentFocusedEditor?.visualCursor?.logicalRow ?? 0, - getCursorOffset: () => api.renderer?.currentFocusedEditor?.cursorOffset ?? 0, - getPlainText: () => getInputText(), - }; + const prompt = { + getLine: (n: number) => getInputText().split("\n")[n] ?? "", + getLineCount: () => getInputText().split("\n").length, + getCursorLine: () => api.renderer?.currentFocusedEditor?.visualCursor?.logicalRow ?? 0, + getCursorOffset: () => api.renderer?.currentFocusedEditor?.cursorOffset ?? 0, + getPlainText: () => getInputText(), + }; - // api.prompt doesn't exist on the TUI plugin API. The actual text lives - // on the focused editor exposed by the renderer. - function getInputText(): string { - return api.renderer?.currentFocusedEditor?.plainText ?? ""; - } + // api.prompt doesn't exist on the TUI plugin API. The actual text lives + // on the focused editor exposed by the renderer. + function getInputText(): string { + return api.renderer?.currentFocusedEditor?.plainText ?? ""; + } - // Read all configured leader keys from OpenCode's keybinds config. - function resolveLeaderKeys(): KeyLike[] { - const bindings = api.tuiConfig?.keybinds?.get?.("leader") ?? []; - return bindings - .map((b: { key?: unknown }) => b.key) - .filter( - (k: unknown): k is KeyLike => - !!k && - k !== "none" && - k !== "false" && - (typeof k === "string" || - (typeof k === "object" && typeof (k as Record).name === "string")), - ); - } + // Read all configured leader keys from OpenCode's keybinds config. + function resolveLeaderKeys(): KeyLike[] { + const bindings = api.tuiConfig?.keybinds?.get?.("leader") ?? []; + return bindings + .map((b: { key?: unknown }) => b.key) + .filter( + (k: unknown): k is KeyLike => + !!k && + k !== "none" && + k !== "false" && + (typeof k === "string" || + (typeof k === "object" && typeof (k as Record).name === "string")), + ); + } - function applyActions(actions: Action[]) { - let keepUndoSnapshotForBatch = false; - for (const action of actions) { - // Buffer-modifying actions (cmd, insertText) clear the undo stack, - // unless this batch includes a saveUndoSnapshot (which sets - // keepUndoSnapshotForBatch to preserve the stack). - if ((action.type === "cmd" || action.type === "insertText") && !keepUndoSnapshotForBatch) { - undoSnapshots = []; - } - switch (action.type) { - case "cmd": - setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0); - break; - case "mode": - if (modeIndicator === "toast") { - const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase(); - api.ui?.toast?.({ - message: label, - variant: "info", - duration: 800, - }); - } - break; - case "toast": - api.ui?.toast?.({ - message: action.message, - variant: "info", - duration: action.duration ?? 2000, - }); - break; - case "yank": - writeClipboard(action.text); - break; - case "insertText": - api.renderer?.currentFocusedEditor?.insertText?.(action.text); - break; - case "yankSelection": { - // Deferred so it runs after any preceding select commands - setTimeout(() => { - const editor = api.renderer?.currentFocusedEditor; - const text = editor?.editorView?.getSelectedText?.() ?? ""; - if (text) { - state.yankRegister = text; - writeClipboard(text); - api.ui?.toast?.({ - message: "yanked", - variant: "info", - duration: 1000, - }); - } - editor?.editorView?.resetSelection?.(); - }, 0); - break; - } - case "clearSelection": - api.renderer?.currentFocusedEditor?.editorView?.resetSelection?.(); - break; - case "deleteRange": { - const editor = api.renderer?.currentFocusedEditor; - const eb = editor?.editBuffer; - if (eb?.deleteRange) { - const text = editor.plainText ?? ""; - const [sl, sc] = offsetToLineCol(text, action.start); - const [el, ec] = offsetToLineCol(text, action.end + 1); - eb.deleteRange(sl, sc, el, ec); - } - break; - } - case "saveUndoSnapshot": { - const editor = api.renderer?.currentFocusedEditor; - if (editor) { - undoSnapshots.push({ - text: editor.plainText ?? "", - cursor: editor.cursorOffset ?? 0, - }); - } - keepUndoSnapshotForBatch = true; - break; - } - case "undo": { - const undoSnapshot = undoSnapshots.pop(); - if (undoSnapshot) { - const editor = api.renderer?.currentFocusedEditor; - const eb = editor?.editBuffer; - if (eb?.setText && editor) { - eb.setText(undoSnapshot.text); - editor.cursorOffset = undoSnapshot.cursor; - } - } else { - setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); - } - break; - } - case "cursorTo": { - const editor = api.renderer?.currentFocusedEditor; - if (editor) editor.cursorOffset = action.offset; - break; - } - case "selectRange": { - const editor = api.renderer?.currentFocusedEditor; - if (editor) { - editor.setSelectionInclusive?.(action.start, action.end); - } - break; - } - } - } - } + function applyActions(actions: Action[]) { + let keepUndoSnapshotForBatch = false; + for (const action of actions) { + // Buffer-modifying actions (cmd, insertText) clear the undo stack, + // unless this batch includes a saveUndoSnapshot (which sets + // keepUndoSnapshotForBatch to preserve the stack). + if ((action.type === "cmd" || action.type === "insertText") && !keepUndoSnapshotForBatch) { + undoSnapshots = []; + } + switch (action.type) { + case "cmd": + setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0); + break; + case "mode": + if (modeIndicator === "toast") { + const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase(); + api.ui?.toast?.({ + message: label, + variant: "info", + duration: 800, + }); + } + break; + case "toast": + api.ui?.toast?.({ + message: action.message, + variant: "info", + duration: action.duration ?? 2000, + }); + break; + case "yank": + writeClipboard(action.text); + break; + case "insertText": + api.renderer?.currentFocusedEditor?.insertText?.(action.text); + break; + case "yankSelection": { + // Deferred so it runs after any preceding select commands + setTimeout(() => { + const editor = api.renderer?.currentFocusedEditor; + const text = editor?.editorView?.getSelectedText?.() ?? ""; + if (text) { + state.yankRegister = text; + writeClipboard(text); + api.ui?.toast?.({ + message: "yanked", + variant: "info", + duration: 1000, + }); + } + editor?.editorView?.resetSelection?.(); + }, 0); + break; + } + case "clearSelection": + api.renderer?.currentFocusedEditor?.editorView?.resetSelection?.(); + break; + case "deleteRange": { + const editor = api.renderer?.currentFocusedEditor; + const eb = editor?.editBuffer; + if (eb?.deleteRange) { + const text = editor.plainText ?? ""; + const [sl, sc] = offsetToLineCol(text, action.start); + const [el, ec] = offsetToLineCol(text, action.end + 1); + eb.deleteRange(sl, sc, el, ec); + } + break; + } + case "saveUndoSnapshot": { + const editor = api.renderer?.currentFocusedEditor; + if (editor) { + undoSnapshots.push({ + text: editor.plainText ?? "", + cursor: editor.cursorOffset ?? 0, + }); + } + keepUndoSnapshotForBatch = true; + break; + } + case "undo": { + const undoSnapshot = undoSnapshots.pop(); + if (undoSnapshot) { + const editor = api.renderer?.currentFocusedEditor; + const eb = editor?.editBuffer; + if (eb?.setText && editor) { + eb.setText(undoSnapshot.text); + editor.cursorOffset = undoSnapshot.cursor; + } + } else { + setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); + } + break; + } + case "cursorTo": { + const editor = api.renderer?.currentFocusedEditor; + if (editor) editor.cursorOffset = action.offset; + break; + } + case "selectRange": { + const editor = api.renderer?.currentFocusedEditor; + if (editor) { + editor.setSelectionInclusive?.(action.start, action.end); + } + break; + } + } + } + } - function syncCursorStyle() { - const editor = api.renderer?.currentFocusedEditor; - if (!editor) return; - editor.cursorStyle = { - style: state.mode === "insert" ? "line" : "block", - blinking: true, - }; - } + function syncCursorStyle() { + const editor = api.renderer?.currentFocusedEditor; + if (!editor) return; + editor.cursorStyle = { + style: state.mode === "insert" ? "line" : "block", + blinking: true, + }; + } - // The Textarea resets cursorStyle during rendering, so re-apply on a - // short interval. Setting a property is cheaper than the previous - // approach of writing DECSCUSR escape sequences to stdout, and works - // in terminals that don't support DECSCUSR (e.g. macOS Terminal.app). - const cursorInterval = setInterval(syncCursorStyle, 100); - api.lifecycle?.onDispose?.(() => clearInterval(cursorInterval)); - api.lifecycle?.onDispose?.(() => { - if (leaderTimer) clearTimeout(leaderTimer); - }); + // The Textarea resets cursorStyle during rendering, so re-apply on a + // short interval. Setting a property is cheaper than the previous + // approach of writing DECSCUSR escape sequences to stdout, and works + // in terminals that don't support DECSCUSR (e.g. macOS Terminal.app). + const cursorInterval = setInterval(syncCursorStyle, 100); + api.lifecycle?.onDispose?.(() => clearInterval(cursorInterval)); + api.lifecycle?.onDispose?.(() => { + if (leaderTimer) clearTimeout(leaderTimer); + }); - if (options?.updateCheck !== false) { - checkForUpdate((opts) => api.ui?.toast?.(opts), api.kv); - } + if (options?.updateCheck !== false) { + checkForUpdate((opts) => api.ui?.toast?.(opts), api.kv); + } - // Register all commands via registerLayer (migrated from the deprecated - // api.command?.register API). Commands appear in the command palette and - // are accessible as slash commands. - const exitRun = async () => { - setTimeout(() => api.keymap.dispatchCommand("app.exit"), 0); - }; - const exitCommands = ["q", "quit", "wq"].map((cmd) => ({ - name: `vimcode.${cmd}`, - title: `:${cmd}`, - category: "Vim", - namespace: "palette", - desc: cmd === "wq" ? "Exit OpenCode (write and quit)" : "Exit OpenCode", - slashName: cmd, - run: exitRun, - })); - // `:w` is the muscle-memory "save" for vim users. Sessions auto-persist, - // so the natural mapping is to send the prompt instead of (as before) - // autocompleting to `:wq` and quitting OpenCode. - const submitRun = async () => { - setTimeout(() => api.keymap.dispatchCommand("input.submit"), 0); - }; - const submitCommands = ["w", "write"].map((cmd) => ({ - name: `vimcode.${cmd}`, - title: `:${cmd}`, - category: "Vim", - namespace: "palette", - desc: "Send prompt", - slashName: cmd, - run: submitRun, - })); - api.keymap.registerLayer?.({ - commands: [ - ...exitCommands, - ...submitCommands, - { - name: "vimcode.vim", - title: ":vim", - category: "Vim", - namespace: "palette", - desc: "Toggle vim mode on/off", - slashName: "vim", - run: async () => { - const result = toggleVimMode(state); - await api.kv?.set?.("vimcode.disabled", state.disabled); - applyActions(result.actions); - }, - }, - ], - }); + // Register all commands via registerLayer (migrated from the deprecated + // api.command?.register API). Commands appear in the command palette and + // are accessible as slash commands. + const exitRun = async () => { + setTimeout(() => api.keymap.dispatchCommand("app.exit"), 0); + }; + const exitCommands = ["q", "quit", "wq"].map((cmd) => ({ + name: `vimcode.${cmd}`, + title: `:${cmd}`, + category: "Vim", + namespace: "palette", + desc: cmd === "wq" ? "Exit OpenCode (write and quit)" : "Exit OpenCode", + slashName: cmd, + run: exitRun, + })); + const submitRun = async () => { + setTimeout(() => api.keymap.dispatchCommand("input.submit"), 0); + }; + const submitCommands = ["w", "write"].map((cmd) => ({ + name: `vimcode.${cmd}`, + title: `:${cmd}`, + category: "Vim", + namespace: "palette", + desc: "Send prompt", + slashName: cmd, + run: submitRun, + })); + api.keymap.registerLayer?.({ + commands: [ + ...exitCommands, + ...submitCommands, + { + name: "vimcode.vim", + title: ":vim", + category: "Vim", + namespace: "palette", + desc: "Toggle vim mode on/off", + slashName: "vim", + run: async () => { + const result = toggleVimMode(state); + await api.kv?.set?.("vimcode.disabled", state.disabled); + applyActions(result.actions); + }, + }, + ], + }); - api.keymap.intercept( - "key", - (ctx) => { - if (ctx.event.eventType === "release") return; + api.keymap.intercept( + "key", + (ctx) => { + if (ctx.event.eventType === "release") return; - // If vim mode is disabled, pass all keys through unmodified. - if (state.disabled) return; + // If vim mode is disabled, pass all keys through unmodified. + if (state.disabled) return; - // Pass through when any overlay owns the keyboard: dialogs (command - // palette, session list, etc.), question prompts, or permission prompts. - if (api.ui?.dialog?.open) return; - const route = api.route.current; - if (route.name === "session") { - const sid = route.params?.sessionID; - if (sid && hasActivePrompts(sid)) { - // Consume the leader key so dispatchLayers() doesn't - // match it as a leader token, which would enter pending- - // sequence state instead of typing a space. - const matched = findMatchingLeader(ctx.event, leaderKeys); - if (matched) { - ctx.consume(); - const ch = leaderChar(matched); - if (ch) api.renderer?.currentFocusedEditor?.insertText?.(ch); - } - return; - } - } + // Pass through when any overlay owns the keyboard: dialogs (command + // palette, session list, etc.), question prompts, or permission prompts. + if (api.ui?.dialog?.open) return; + const route = api.route.current; + if (route.name === "session") { + const sid = route.params?.sessionID; + if (sid && hasActivePrompts(sid)) { + // Consume the leader key so dispatchLayers() doesn't + // match it as a leader token, which would enter pending- + // sequence state instead of typing a space. + const matched = findMatchingLeader(ctx.event, leaderKeys); + if (matched) { + ctx.consume(); + const ch = leaderChar(matched); + if (ch) api.renderer?.currentFocusedEditor?.insertText?.(ch); + } + return; + } + } - // Let autocomplete handle Enter/Escape before vim consumes them. - // dispatchCommand returns { ok } — true when the autocomplete layer - // is active and handled the command, false when it's hidden/disabled. - if (state.mode === "insert") { - if (ctx.event.name === "escape") { - const r = api.keymap.dispatchCommand("prompt.autocomplete.hide"); - if (r.ok) { - ctx.consume(); - return; - } - } - if (ctx.event.name === "return" && !ctx.event.ctrl) { - const r = api.keymap.dispatchCommand("prompt.autocomplete.select"); - if (r.ok) { - ctx.consume(); - return; - } - } - } + // Let autocomplete handle Enter/Escape before vim consumes them. + // dispatchCommand returns { ok } — true when the autocomplete layer + // is active and handled the command, false when it's hidden/disabled. + if (state.mode === "insert") { + if (ctx.event.name === "escape") { + const r = api.keymap.dispatchCommand("prompt.autocomplete.hide"); + if (r.ok) { + ctx.consume(); + return; + } + } + if (ctx.event.name === "return" && !ctx.event.ctrl) { + const r = api.keymap.dispatchCommand("prompt.autocomplete.select"); + if (r.ok) { + ctx.consume(); + return; + } + } + } - const key = translateKey(ctx.event); + const key = translateKey(ctx.event); - // In normal/visual mode, let the leader key and its follow-up - // pass through so OpenCode's leader bindings work. - if (leaderKeys.length > 0 && state.mode !== "insert") { - if (leaderPending) { - leaderPending = false; - if (leaderTimer) clearTimeout(leaderTimer); - return; - } - if (findMatchingLeader(ctx.event, leaderKeys)) { - leaderPending = true; - leaderTimer = setTimeout(() => { - leaderPending = false; - }, 2000); - return; - } - } + // In normal/visual mode, let the leader key and its follow-up + // pass through so OpenCode's leader bindings work. + if (leaderKeys.length > 0 && state.mode !== "insert") { + if (leaderPending) { + leaderPending = false; + if (leaderTimer) clearTimeout(leaderTimer); + return; + } + if (findMatchingLeader(ctx.event, leaderKeys)) { + leaderPending = true; + leaderTimer = setTimeout(() => { + leaderPending = false; + }, 2000); + return; + } + } - const handlerMode = state.mode; - const result = - state.mode === "insert" - ? handleInsertKey(state, key, ctx.event, prompt) - : state.mode === "visual" - ? handleVisualKey(state, key, ctx.event, prompt) - : handleNormalKey(state, key, ctx.event, prompt); - if (handlerMode === "normal") finishOneShotIfComplete(state, result); + const handlerMode = state.mode; + const result = + state.mode === "insert" + ? handleInsertKey(state, key, ctx.event, prompt) + : state.mode === "visual" + ? handleVisualKey(state, key, ctx.event, prompt) + : handleNormalKey(state, key, ctx.event, prompt); + if (handlerMode === "normal") finishOneShotIfComplete(state, result); - // In insert mode, intercept printable leaders (space, "a") so - // they insert their character instead of triggering the leader - // menu mid-typing. Non-printable leaders (ctrl+x, alt+m) fall - // through to dispatchLayers() so app-level shortcuts work - // without switching modes. Runs after handleInsertKey so - // explicit handlers (escape, return, tab, ctrl+o) take priority. - // Don't mutate `result` — it may be the shared PASS constant. - let consume = result.consume; - let actions = result.actions; - if (handlerMode === "insert" && !consume && leaderKeys.length > 0) { - const matched = findMatchingLeader(ctx.event, leaderKeys); - if (matched) { - const ch = leaderChar(matched); - if (ch) { - actions = [{ type: "insertText" as const, text: ch }]; - consume = true; - } - } - } + // In insert mode, intercept printable leaders (space, "a") so + // they insert their character instead of triggering the leader + // menu mid-typing. Non-printable leaders (ctrl+x, alt+m) fall + // through to dispatchLayers() so app-level shortcuts work + // without switching modes. Runs after handleInsertKey so + // explicit handlers (escape, return, tab, ctrl+o) take priority. + // Don't mutate `result` — it may be the shared PASS constant. + let consume = result.consume; + let actions = result.actions; + if (handlerMode === "insert" && !consume && leaderKeys.length > 0) { + const matched = findMatchingLeader(ctx.event, leaderKeys); + if (matched) { + const ch = leaderChar(matched); + if (ch) { + actions = [{ type: "insertText" as const, text: ch }]; + consume = true; + } + } + } - if (consume) ctx.consume(); - applyActions(actions); - }, - { priority: 10_000 }, - ); - }, + if (consume) ctx.consume(); + applyActions(actions); + }, + { priority: 10_000 }, + ); + }, }; function offsetToLineCol(text: string, offset: number): [number, number] { - const before = text.substring(0, offset); - const lines = before.split("\n"); - return [lines.length - 1, lines[lines.length - 1].length]; + const before = text.substring(0, offset); + const lines = before.split("\n"); + return [lines.length - 1, lines[lines.length - 1].length]; } export default plugin; From 837bcc649641f676e9b0f11412b917bfc8148092 Mon Sep 17 00:00:00 2001 From: Dorian Moy Date: Tue, 28 Jul 2026 15:10:14 +0200 Subject: [PATCH 5/5] style: formatting --- src/index.ts | 738 +++++++++++++++++++++++++-------------------------- 1 file changed, 369 insertions(+), 369 deletions(-) diff --git a/src/index.ts b/src/index.ts index e190009..f5f6433 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,406 +3,406 @@ import { writeClipboard } from "./clipboard"; import { findMatchingLeader, type KeyLike, leaderChar } from "./leader"; import { checkForUpdate } from "./version"; import { - type Action, - createVimState, - finishOneShotIfComplete, - handleInsertKey, - handleNormalKey, - handleVisualKey, - toggleVimMode, - translateKey, + type Action, + createVimState, + finishOneShotIfComplete, + handleInsertKey, + handleNormalKey, + handleVisualKey, + toggleVimMode, + translateKey, } from "./vim"; const plugin: TuiPluginModule = { - id: "vimcode", - tui: async (api, options) => { - const state = createVimState(); - const startMode = options?.startMode === "normal" ? "normal" : "insert"; - state.mode = startMode; - const leaderKeys = resolveLeaderKeys(); + id: "vimcode", + tui: async (api, options) => { + const state = createVimState(); + const startMode = options?.startMode === "normal" ? "normal" : "insert"; + state.mode = startMode; + const leaderKeys = resolveLeaderKeys(); - // Resolve modeIndicator: "toast" (default) or "none". - // Backward compat: modeToast:false maps to "none", but only if - // modeIndicator isn't explicitly set. - const modeIndicator: "toast" | "none" = - options?.modeIndicator === "toast" || options?.modeIndicator === "none" - ? options.modeIndicator - : options?.modeToast === false - ? "none" - : "toast"; + // Resolve modeIndicator: "toast" (default) or "none". + // Backward compat: modeToast:false maps to "none", but only if + // modeIndicator isn't explicitly set. + const modeIndicator: "toast" | "none" = + options?.modeIndicator === "toast" || options?.modeIndicator === "none" + ? options.modeIndicator + : options?.modeToast === false + ? "none" + : "toast"; - // Load persisted disabled state - const persistedDisabled = (await api.kv?.get?.("vimcode.disabled")) as boolean | undefined; - state.disabled = persistedDisabled ?? false; - if (state.disabled) { - api.ui?.toast?.({ message: "Vim mode disabled (use /vim to re-enable)", variant: "info", duration: 3000 }); - } + // Load persisted disabled state + const persistedDisabled = (await api.kv?.get?.("vimcode.disabled")) as boolean | undefined; + state.disabled = persistedDisabled ?? false; + if (state.disabled) { + api.ui?.toast?.({ message: "Vim mode disabled (use /vim to re-enable)", variant: "info", duration: 3000 }); + } - // Track whether the previous key was the leader, so the follow-up - // key also passes through to OpenCode's leader system. - let leaderPending = false; - let leaderTimer: ReturnType | null = null; + // Track whether the previous key was the leader, so the follow-up + // key also passes through to OpenCode's leader system. + let leaderPending = false; + let leaderTimer: ReturnType | null = null; - // Track pending permissions/questions from child sessions via events. - // permission()/question() only covers one session ID, but subagent - // prompts live on child IDs. Events fire globally; we aggregate by root. - const pendingChildPrompts = new Map(); + // Track pending permissions/questions from child sessions via events. + // permission()/question() only covers one session ID, but subagent + // prompts live on child IDs. Events fire globally; we aggregate by root. + const pendingChildPrompts = new Map(); - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - function trackPromptEvent(event: any, delta: number) { - const sessionID = event?.properties?.sessionID ?? event?.sessionID; - if (!sessionID) return; - const session = api.state?.session?.get?.(sessionID); - const rootId = session?.parentID ?? sessionID; - const count = (pendingChildPrompts.get(rootId) ?? 0) + delta; - if (count <= 0) pendingChildPrompts.delete(rootId); - else pendingChildPrompts.set(rootId, count); - } + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + function trackPromptEvent(event: any, delta: number) { + const sessionID = event?.properties?.sessionID ?? event?.sessionID; + if (!sessionID) return; + const session = api.state?.session?.get?.(sessionID); + const rootId = session?.parentID ?? sessionID; + const count = (pendingChildPrompts.get(rootId) ?? 0) + delta; + if (count <= 0) pendingChildPrompts.delete(rootId); + else pendingChildPrompts.set(rootId, count); + } - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubPermsAsked = api.event?.on?.("permission.asked", (e: any) => trackPromptEvent(e, 1)); - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubPermsReplied = api.event?.on?.("permission.replied", (e: any) => trackPromptEvent(e, -1)); - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestAsked = api.event?.on?.("question.asked", (e: any) => trackPromptEvent(e, 1)); - // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API - const unsubQuestReplied = api.event?.on?.("question.replied", (e: any) => trackPromptEvent(e, -1)); - api.lifecycle?.onDispose?.(() => { - unsubPermsAsked?.(); - unsubPermsReplied?.(); - unsubQuestAsked?.(); - unsubQuestReplied?.(); - }); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubPermsAsked = api.event?.on?.("permission.asked", (e: any) => trackPromptEvent(e, 1)); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubPermsReplied = api.event?.on?.("permission.replied", (e: any) => trackPromptEvent(e, -1)); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubQuestAsked = api.event?.on?.("question.asked", (e: any) => trackPromptEvent(e, 1)); + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubQuestReplied = api.event?.on?.("question.replied", (e: any) => trackPromptEvent(e, -1)); + api.lifecycle?.onDispose?.(() => { + unsubPermsAsked?.(); + unsubPermsReplied?.(); + unsubQuestAsked?.(); + unsubQuestReplied?.(); + }); - function hasActivePrompts(sid: string): boolean { - const q = api.state.session.question(sid); - if (q && q.length > 0) return true; - const p = api.state.session.permission(sid); - if (p && p.length > 0) return true; - return (pendingChildPrompts.get(sid) ?? 0) > 0; - } + function hasActivePrompts(sid: string): boolean { + const q = api.state.session.question(sid); + if (q && q.length > 0) return true; + const p = api.state.session.permission(sid); + if (p && p.length > 0) return true; + return (pendingChildPrompts.get(sid) ?? 0) > 0; + } - // Snapshots for single-step undo of vim changes. - // The host editor's undo system splits repeated commands into multiple - // entries, so we save/restore the buffer ourselves. - let undoSnapshots: Array<{ text: string; cursor: number }> = []; + // Snapshots for single-step undo of vim changes. + // The host editor's undo system splits repeated commands into multiple + // entries, so we save/restore the buffer ourselves. + let undoSnapshots: Array<{ text: string; cursor: number }> = []; - const prompt = { - getLine: (n: number) => getInputText().split("\n")[n] ?? "", - getLineCount: () => getInputText().split("\n").length, - getCursorLine: () => api.renderer?.currentFocusedEditor?.visualCursor?.logicalRow ?? 0, - getCursorOffset: () => api.renderer?.currentFocusedEditor?.cursorOffset ?? 0, - getPlainText: () => getInputText(), - }; + const prompt = { + getLine: (n: number) => getInputText().split("\n")[n] ?? "", + getLineCount: () => getInputText().split("\n").length, + getCursorLine: () => api.renderer?.currentFocusedEditor?.visualCursor?.logicalRow ?? 0, + getCursorOffset: () => api.renderer?.currentFocusedEditor?.cursorOffset ?? 0, + getPlainText: () => getInputText(), + }; - // api.prompt doesn't exist on the TUI plugin API. The actual text lives - // on the focused editor exposed by the renderer. - function getInputText(): string { - return api.renderer?.currentFocusedEditor?.plainText ?? ""; - } + // api.prompt doesn't exist on the TUI plugin API. The actual text lives + // on the focused editor exposed by the renderer. + function getInputText(): string { + return api.renderer?.currentFocusedEditor?.plainText ?? ""; + } - // Read all configured leader keys from OpenCode's keybinds config. - function resolveLeaderKeys(): KeyLike[] { - const bindings = api.tuiConfig?.keybinds?.get?.("leader") ?? []; - return bindings - .map((b: { key?: unknown }) => b.key) - .filter( - (k: unknown): k is KeyLike => - !!k && - k !== "none" && - k !== "false" && - (typeof k === "string" || - (typeof k === "object" && typeof (k as Record).name === "string")), - ); - } + // Read all configured leader keys from OpenCode's keybinds config. + function resolveLeaderKeys(): KeyLike[] { + const bindings = api.tuiConfig?.keybinds?.get?.("leader") ?? []; + return bindings + .map((b: { key?: unknown }) => b.key) + .filter( + (k: unknown): k is KeyLike => + !!k && + k !== "none" && + k !== "false" && + (typeof k === "string" || + (typeof k === "object" && typeof (k as Record).name === "string")), + ); + } - function applyActions(actions: Action[]) { - let keepUndoSnapshotForBatch = false; - for (const action of actions) { - // Buffer-modifying actions (cmd, insertText) clear the undo stack, - // unless this batch includes a saveUndoSnapshot (which sets - // keepUndoSnapshotForBatch to preserve the stack). - if ((action.type === "cmd" || action.type === "insertText") && !keepUndoSnapshotForBatch) { - undoSnapshots = []; - } - switch (action.type) { - case "cmd": - setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0); - break; - case "mode": - if (modeIndicator === "toast") { - const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase(); - api.ui?.toast?.({ - message: label, - variant: "info", - duration: 800, - }); - } - break; - case "toast": - api.ui?.toast?.({ - message: action.message, - variant: "info", - duration: action.duration ?? 2000, - }); - break; - case "yank": - writeClipboard(action.text); - break; - case "insertText": - api.renderer?.currentFocusedEditor?.insertText?.(action.text); - break; - case "yankSelection": { - // Deferred so it runs after any preceding select commands - setTimeout(() => { - const editor = api.renderer?.currentFocusedEditor; - const text = editor?.editorView?.getSelectedText?.() ?? ""; - if (text) { - state.yankRegister = text; - writeClipboard(text); - api.ui?.toast?.({ - message: "yanked", - variant: "info", - duration: 1000, - }); - } - editor?.editorView?.resetSelection?.(); - }, 0); - break; - } - case "clearSelection": - api.renderer?.currentFocusedEditor?.editorView?.resetSelection?.(); - break; - case "deleteRange": { - const editor = api.renderer?.currentFocusedEditor; - const eb = editor?.editBuffer; - if (eb?.deleteRange) { - const text = editor.plainText ?? ""; - const [sl, sc] = offsetToLineCol(text, action.start); - const [el, ec] = offsetToLineCol(text, action.end + 1); - eb.deleteRange(sl, sc, el, ec); - } - break; - } - case "saveUndoSnapshot": { - const editor = api.renderer?.currentFocusedEditor; - if (editor) { - undoSnapshots.push({ - text: editor.plainText ?? "", - cursor: editor.cursorOffset ?? 0, - }); - } - keepUndoSnapshotForBatch = true; - break; - } - case "undo": { - const undoSnapshot = undoSnapshots.pop(); - if (undoSnapshot) { - const editor = api.renderer?.currentFocusedEditor; - const eb = editor?.editBuffer; - if (eb?.setText && editor) { - eb.setText(undoSnapshot.text); - editor.cursorOffset = undoSnapshot.cursor; - } - } else { - setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); - } - break; - } - case "cursorTo": { - const editor = api.renderer?.currentFocusedEditor; - if (editor) editor.cursorOffset = action.offset; - break; - } - case "selectRange": { - const editor = api.renderer?.currentFocusedEditor; - if (editor) { - editor.setSelectionInclusive?.(action.start, action.end); - } - break; - } - } - } - } + function applyActions(actions: Action[]) { + let keepUndoSnapshotForBatch = false; + for (const action of actions) { + // Buffer-modifying actions (cmd, insertText) clear the undo stack, + // unless this batch includes a saveUndoSnapshot (which sets + // keepUndoSnapshotForBatch to preserve the stack). + if ((action.type === "cmd" || action.type === "insertText") && !keepUndoSnapshotForBatch) { + undoSnapshots = []; + } + switch (action.type) { + case "cmd": + setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0); + break; + case "mode": + if (modeIndicator === "toast") { + const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase(); + api.ui?.toast?.({ + message: label, + variant: "info", + duration: 800, + }); + } + break; + case "toast": + api.ui?.toast?.({ + message: action.message, + variant: "info", + duration: action.duration ?? 2000, + }); + break; + case "yank": + writeClipboard(action.text); + break; + case "insertText": + api.renderer?.currentFocusedEditor?.insertText?.(action.text); + break; + case "yankSelection": { + // Deferred so it runs after any preceding select commands + setTimeout(() => { + const editor = api.renderer?.currentFocusedEditor; + const text = editor?.editorView?.getSelectedText?.() ?? ""; + if (text) { + state.yankRegister = text; + writeClipboard(text); + api.ui?.toast?.({ + message: "yanked", + variant: "info", + duration: 1000, + }); + } + editor?.editorView?.resetSelection?.(); + }, 0); + break; + } + case "clearSelection": + api.renderer?.currentFocusedEditor?.editorView?.resetSelection?.(); + break; + case "deleteRange": { + const editor = api.renderer?.currentFocusedEditor; + const eb = editor?.editBuffer; + if (eb?.deleteRange) { + const text = editor.plainText ?? ""; + const [sl, sc] = offsetToLineCol(text, action.start); + const [el, ec] = offsetToLineCol(text, action.end + 1); + eb.deleteRange(sl, sc, el, ec); + } + break; + } + case "saveUndoSnapshot": { + const editor = api.renderer?.currentFocusedEditor; + if (editor) { + undoSnapshots.push({ + text: editor.plainText ?? "", + cursor: editor.cursorOffset ?? 0, + }); + } + keepUndoSnapshotForBatch = true; + break; + } + case "undo": { + const undoSnapshot = undoSnapshots.pop(); + if (undoSnapshot) { + const editor = api.renderer?.currentFocusedEditor; + const eb = editor?.editBuffer; + if (eb?.setText && editor) { + eb.setText(undoSnapshot.text); + editor.cursorOffset = undoSnapshot.cursor; + } + } else { + setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); + } + break; + } + case "cursorTo": { + const editor = api.renderer?.currentFocusedEditor; + if (editor) editor.cursorOffset = action.offset; + break; + } + case "selectRange": { + const editor = api.renderer?.currentFocusedEditor; + if (editor) { + editor.setSelectionInclusive?.(action.start, action.end); + } + break; + } + } + } + } - function syncCursorStyle() { - const editor = api.renderer?.currentFocusedEditor; - if (!editor) return; - editor.cursorStyle = { - style: state.mode === "insert" ? "line" : "block", - blinking: true, - }; - } + function syncCursorStyle() { + const editor = api.renderer?.currentFocusedEditor; + if (!editor) return; + editor.cursorStyle = { + style: state.mode === "insert" ? "line" : "block", + blinking: true, + }; + } - // The Textarea resets cursorStyle during rendering, so re-apply on a - // short interval. Setting a property is cheaper than the previous - // approach of writing DECSCUSR escape sequences to stdout, and works - // in terminals that don't support DECSCUSR (e.g. macOS Terminal.app). - const cursorInterval = setInterval(syncCursorStyle, 100); - api.lifecycle?.onDispose?.(() => clearInterval(cursorInterval)); - api.lifecycle?.onDispose?.(() => { - if (leaderTimer) clearTimeout(leaderTimer); - }); + // The Textarea resets cursorStyle during rendering, so re-apply on a + // short interval. Setting a property is cheaper than the previous + // approach of writing DECSCUSR escape sequences to stdout, and works + // in terminals that don't support DECSCUSR (e.g. macOS Terminal.app). + const cursorInterval = setInterval(syncCursorStyle, 100); + api.lifecycle?.onDispose?.(() => clearInterval(cursorInterval)); + api.lifecycle?.onDispose?.(() => { + if (leaderTimer) clearTimeout(leaderTimer); + }); - if (options?.updateCheck !== false) { - checkForUpdate((opts) => api.ui?.toast?.(opts), api.kv); - } + if (options?.updateCheck !== false) { + checkForUpdate((opts) => api.ui?.toast?.(opts), api.kv); + } - // Register all commands via registerLayer (migrated from the deprecated - // api.command?.register API). Commands appear in the command palette and - // are accessible as slash commands. - const exitRun = async () => { - setTimeout(() => api.keymap.dispatchCommand("app.exit"), 0); - }; - const exitCommands = ["q", "quit", "wq"].map((cmd) => ({ - name: `vimcode.${cmd}`, - title: `:${cmd}`, - category: "Vim", - namespace: "palette", - desc: cmd === "wq" ? "Exit OpenCode (write and quit)" : "Exit OpenCode", - slashName: cmd, - run: exitRun, - })); - const submitRun = async () => { - setTimeout(() => api.keymap.dispatchCommand("input.submit"), 0); - }; - const submitCommands = ["w", "write"].map((cmd) => ({ - name: `vimcode.${cmd}`, - title: `:${cmd}`, - category: "Vim", - namespace: "palette", - desc: "Send prompt", - slashName: cmd, - run: submitRun, - })); - api.keymap.registerLayer?.({ - commands: [ - ...exitCommands, - ...submitCommands, - { - name: "vimcode.vim", - title: ":vim", - category: "Vim", - namespace: "palette", - desc: "Toggle vim mode on/off", - slashName: "vim", - run: async () => { - const result = toggleVimMode(state); - await api.kv?.set?.("vimcode.disabled", state.disabled); - applyActions(result.actions); - }, - }, - ], - }); + // Register all commands via registerLayer (migrated from the deprecated + // api.command?.register API). Commands appear in the command palette and + // are accessible as slash commands. + const exitRun = async () => { + setTimeout(() => api.keymap.dispatchCommand("app.exit"), 0); + }; + const exitCommands = ["q", "quit", "wq"].map((cmd) => ({ + name: `vimcode.${cmd}`, + title: `:${cmd}`, + category: "Vim", + namespace: "palette", + desc: cmd === "wq" ? "Exit OpenCode (write and quit)" : "Exit OpenCode", + slashName: cmd, + run: exitRun, + })); + const submitRun = async () => { + setTimeout(() => api.keymap.dispatchCommand("input.submit"), 0); + }; + const submitCommands = ["w", "write"].map((cmd) => ({ + name: `vimcode.${cmd}`, + title: `:${cmd}`, + category: "Vim", + namespace: "palette", + desc: "Send prompt", + slashName: cmd, + run: submitRun, + })); + api.keymap.registerLayer?.({ + commands: [ + ...exitCommands, + ...submitCommands, + { + name: "vimcode.vim", + title: ":vim", + category: "Vim", + namespace: "palette", + desc: "Toggle vim mode on/off", + slashName: "vim", + run: async () => { + const result = toggleVimMode(state); + await api.kv?.set?.("vimcode.disabled", state.disabled); + applyActions(result.actions); + }, + }, + ], + }); - api.keymap.intercept( - "key", - (ctx) => { - if (ctx.event.eventType === "release") return; + api.keymap.intercept( + "key", + (ctx) => { + if (ctx.event.eventType === "release") return; - // If vim mode is disabled, pass all keys through unmodified. - if (state.disabled) return; + // If vim mode is disabled, pass all keys through unmodified. + if (state.disabled) return; - // Pass through when any overlay owns the keyboard: dialogs (command - // palette, session list, etc.), question prompts, or permission prompts. - if (api.ui?.dialog?.open) return; - const route = api.route.current; - if (route.name === "session") { - const sid = route.params?.sessionID; - if (sid && hasActivePrompts(sid)) { - // Consume the leader key so dispatchLayers() doesn't - // match it as a leader token, which would enter pending- - // sequence state instead of typing a space. - const matched = findMatchingLeader(ctx.event, leaderKeys); - if (matched) { - ctx.consume(); - const ch = leaderChar(matched); - if (ch) api.renderer?.currentFocusedEditor?.insertText?.(ch); - } - return; - } - } + // Pass through when any overlay owns the keyboard: dialogs (command + // palette, session list, etc.), question prompts, or permission prompts. + if (api.ui?.dialog?.open) return; + const route = api.route.current; + if (route.name === "session") { + const sid = route.params?.sessionID; + if (sid && hasActivePrompts(sid)) { + // Consume the leader key so dispatchLayers() doesn't + // match it as a leader token, which would enter pending- + // sequence state instead of typing a space. + const matched = findMatchingLeader(ctx.event, leaderKeys); + if (matched) { + ctx.consume(); + const ch = leaderChar(matched); + if (ch) api.renderer?.currentFocusedEditor?.insertText?.(ch); + } + return; + } + } - // Let autocomplete handle Enter/Escape before vim consumes them. - // dispatchCommand returns { ok } — true when the autocomplete layer - // is active and handled the command, false when it's hidden/disabled. - if (state.mode === "insert") { - if (ctx.event.name === "escape") { - const r = api.keymap.dispatchCommand("prompt.autocomplete.hide"); - if (r.ok) { - ctx.consume(); - return; - } - } - if (ctx.event.name === "return" && !ctx.event.ctrl) { - const r = api.keymap.dispatchCommand("prompt.autocomplete.select"); - if (r.ok) { - ctx.consume(); - return; - } - } - } + // Let autocomplete handle Enter/Escape before vim consumes them. + // dispatchCommand returns { ok } — true when the autocomplete layer + // is active and handled the command, false when it's hidden/disabled. + if (state.mode === "insert") { + if (ctx.event.name === "escape") { + const r = api.keymap.dispatchCommand("prompt.autocomplete.hide"); + if (r.ok) { + ctx.consume(); + return; + } + } + if (ctx.event.name === "return" && !ctx.event.ctrl) { + const r = api.keymap.dispatchCommand("prompt.autocomplete.select"); + if (r.ok) { + ctx.consume(); + return; + } + } + } - const key = translateKey(ctx.event); + const key = translateKey(ctx.event); - // In normal/visual mode, let the leader key and its follow-up - // pass through so OpenCode's leader bindings work. - if (leaderKeys.length > 0 && state.mode !== "insert") { - if (leaderPending) { - leaderPending = false; - if (leaderTimer) clearTimeout(leaderTimer); - return; - } - if (findMatchingLeader(ctx.event, leaderKeys)) { - leaderPending = true; - leaderTimer = setTimeout(() => { - leaderPending = false; - }, 2000); - return; - } - } + // In normal/visual mode, let the leader key and its follow-up + // pass through so OpenCode's leader bindings work. + if (leaderKeys.length > 0 && state.mode !== "insert") { + if (leaderPending) { + leaderPending = false; + if (leaderTimer) clearTimeout(leaderTimer); + return; + } + if (findMatchingLeader(ctx.event, leaderKeys)) { + leaderPending = true; + leaderTimer = setTimeout(() => { + leaderPending = false; + }, 2000); + return; + } + } - const handlerMode = state.mode; - const result = - state.mode === "insert" - ? handleInsertKey(state, key, ctx.event, prompt) - : state.mode === "visual" - ? handleVisualKey(state, key, ctx.event, prompt) - : handleNormalKey(state, key, ctx.event, prompt); - if (handlerMode === "normal") finishOneShotIfComplete(state, result); + const handlerMode = state.mode; + const result = + state.mode === "insert" + ? handleInsertKey(state, key, ctx.event, prompt) + : state.mode === "visual" + ? handleVisualKey(state, key, ctx.event, prompt) + : handleNormalKey(state, key, ctx.event, prompt); + if (handlerMode === "normal") finishOneShotIfComplete(state, result); - // In insert mode, intercept printable leaders (space, "a") so - // they insert their character instead of triggering the leader - // menu mid-typing. Non-printable leaders (ctrl+x, alt+m) fall - // through to dispatchLayers() so app-level shortcuts work - // without switching modes. Runs after handleInsertKey so - // explicit handlers (escape, return, tab, ctrl+o) take priority. - // Don't mutate `result` — it may be the shared PASS constant. - let consume = result.consume; - let actions = result.actions; - if (handlerMode === "insert" && !consume && leaderKeys.length > 0) { - const matched = findMatchingLeader(ctx.event, leaderKeys); - if (matched) { - const ch = leaderChar(matched); - if (ch) { - actions = [{ type: "insertText" as const, text: ch }]; - consume = true; - } - } - } + // In insert mode, intercept printable leaders (space, "a") so + // they insert their character instead of triggering the leader + // menu mid-typing. Non-printable leaders (ctrl+x, alt+m) fall + // through to dispatchLayers() so app-level shortcuts work + // without switching modes. Runs after handleInsertKey so + // explicit handlers (escape, return, tab, ctrl+o) take priority. + // Don't mutate `result` — it may be the shared PASS constant. + let consume = result.consume; + let actions = result.actions; + if (handlerMode === "insert" && !consume && leaderKeys.length > 0) { + const matched = findMatchingLeader(ctx.event, leaderKeys); + if (matched) { + const ch = leaderChar(matched); + if (ch) { + actions = [{ type: "insertText" as const, text: ch }]; + consume = true; + } + } + } - if (consume) ctx.consume(); - applyActions(actions); - }, - { priority: 10_000 }, - ); - }, + if (consume) ctx.consume(); + applyActions(actions); + }, + { priority: 10_000 }, + ); + }, }; function offsetToLineCol(text: string, offset: number): [number, number] { - const before = text.substring(0, offset); - const lines = before.split("\n"); - return [lines.length - 1, lines[lines.length - 1].length]; + const before = text.substring(0, offset); + const lines = before.split("\n"); + return [lines.length - 1, lines[lines.length - 1].length]; } export default plugin;