Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/vim/normal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/vim/visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> = {}) => {
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);
});
});
14 changes: 14 additions & 0 deletions test/vim/normal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
}
});
18 changes: 18 additions & 0 deletions test/vim/visual.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
}
});
Loading