From a59deb8f6d6c0c57133b91db58f54f90cce9f96c Mon Sep 17 00:00:00 2001 From: ori Date: Wed, 2 Sep 2026 00:57:59 +0300 Subject: [PATCH] fix: pass arrow keys through in normal and visual mode handleNormalKey and handleVisualKey ended with a catch-all that consumes any unbound key. Arrow keys aren't vim motions (only hjkl are), so they hit that catch-all and were swallowed, and the intercept then called ctx.consume(), so OpenCode never received the key. In the subagent view that trapped users (issue #63): after Esc to enter normal mode, arrow_up did nothing, and the only way out was the non-obvious workaround of pressing i to re-enter insert mode, where arrows already passed through. Return PASS for up/down/left/right in both handlers so the host handles them (move the cursor in the prompt, exit the subagent view, etc.), now consistent with insert mode. In visual mode arrows move the cursor via the host rather than extending the selection; extending on arrows can be a follow-up. Fixes #63 --- CHANGELOG.md | 4 +++ src/vim/normal.ts | 7 +++++ src/vim/visual.ts | 4 +++ test/integration.test.ts | 59 ++++++++++++++++++++++++++++++++++++++++ test/vim/normal.test.ts | 14 ++++++++++ test/vim/visual.test.ts | 18 ++++++++++++ 6 files changed, 106 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e38684b..83ec280 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] +### Fixed + +- Arrow keys no longer get swallowed in normal and visual mode. They pass through to OpenCode, so you can exit the subagent view (and use other native navigation) without first switching to insert mode ([#63](https://github.com/oribarilan/vimcode/issues/63)). + ## [0.17.0] — 2026-09-02 ### Changed diff --git a/src/vim/normal.ts b/src/vim/normal.ts index 0c14fae..049f6c7 100644 --- a/src/vim/normal.ts +++ b/src/vim/normal.ts @@ -14,6 +14,13 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return PASS; } + // Arrow keys are host navigation, not vim motions. Pass them through so + // OpenCode handles them (e.g. exiting the subagent view from normal mode, + // issue #63). Consuming them here would swallow the key and trap the user. + if (ev.name === "up" || ev.name === "down" || ev.name === "left" || ev.name === "right") { + return PASS; + } + if (ev.name === "escape") { if (state.oneShotNormal) { state.oneShotNormal = false; diff --git a/src/vim/visual.ts b/src/vim/visual.ts index dc9a5b6..0f5f815 100644 --- a/src/vim/visual.ts +++ b/src/vim/visual.ts @@ -8,6 +8,10 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom if (ev.meta || ev.super) return PASS; if (ev.ctrl) return PASS; + // Arrow keys are host navigation, not selection motions, so pass them + // through so vimcode never traps them (issue #63). + if (ev.name === "up" || ev.name === "down" || ev.name === "left" || ev.name === "right") return PASS; + const actions: Action[] = []; // Pending g prefix in visual mode diff --git a/test/integration.test.ts b/test/integration.test.ts index 14ea12e..c109d0b 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -416,3 +416,62 @@ describe("undo snapshot — deleteRange + u", () => { expect(dispatched).toContain("input.undo"); }); }); + +// ── arrow keys pass through the intercept (issue #63) ───── + +describe("arrow keys pass through the intercept", () => { + // #63: in normal mode the intercept consumed arrow keys, so OpenCode never + // saw them and couldn't exit the subagent view. This drives the real + // pipeline (plugin.tui → key intercept) and asserts consume() is not called + // for arrows, while a vim motion still is. + async function setup() { + const plugin = (await import("../src/index")).default; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + + const api = { + renderer: { currentFocusedEditor: undefined }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_e: string, h: typeof handler) => { + handler = h; + }, + dispatchCommand: () => ({ ok: false }), + }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: mock API + await plugin.tui(api as any, { updateCheck: false } as any, undefined as any); + + // Returns whether the intercept consumed the key (i.e. called consume()). + const press = (name: string, opts: Record = {}) => { + let consumed = false; + handler?.({ + event: { name, eventType: "press", ...opts }, + consume: () => { + consumed = true; + }, + }); + return consumed; + }; + + press("escape"); // leave insert, enter normal mode + return { press }; + } + + for (const arrow of ["up", "down", "left", "right"] as const) { + it(`${arrow} in normal mode is not consumed, so the host handles it`, async () => { + const { press } = await setup(); + expect(press(arrow)).toBe(false); + }); + } + + it("a vim motion (j) is still consumed, proving the harness detects consumption", async () => { + const { press } = await setup(); + expect(press("j")).toBe(true); + }); +}); diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts index aa794aa..965322d 100644 --- a/test/vim/normal.test.ts +++ b/test/vim/normal.test.ts @@ -653,3 +653,17 @@ describe("handleNormalKey — pending cleanup", () => { expect(cmds(r.actions)).toEqual(["input.word.forward"]); }); }); + +// ── handleNormalKey — arrow keys pass through (issue #63) ── + +describe("handleNormalKey — arrow keys pass through", () => { + // Arrows are host navigation, not vim motions. If vimcode consumes them, + // OpenCode can't exit the subagent view from normal mode (issue #63). + for (const arrow of ["up", "down", "left", "right"] as const) { + it(`${arrow} passes through to the host without consuming`, () => { + const r = handleNormalKey(state, arrow, ev(arrow), mockPrompt); + expect(r.consume).toBe(false); + expect(r.actions).toEqual([]); + }); + } +}); diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts index edaa5f8..ba48949 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -193,3 +193,21 @@ describe("handleVisualKey — exit and passthrough", () => { expect(r.actions).toEqual([]); }); }); + +// ── handleVisualKey — arrow keys pass through (issue #63) ── + +describe("handleVisualKey — arrow keys pass through", () => { + beforeEach(() => { + state.mode = "visual"; + }); + + // Arrows are host navigation, not selection motions. Consuming them would + // trap the user the same way normal mode did before issue #63. + for (const arrow of ["up", "down", "left", "right"] as const) { + it(`${arrow} passes through to the host without consuming`, () => { + const r = handleVisualKey(state, arrow, ev(arrow), mockPrompt); + expect(r.consume).toBe(false); + expect(r.actions).toEqual([]); + }); + } +});