diff --git a/AGENTS.md b/AGENTS.md index 6752480..6e74784 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (408 lines) Plugin entry: intercept registration, action application + index.ts (414 lines) Plugin entry: intercept registration, action application vim/ Pure vim engine (thin barrel re-exports the public surface): index.ts (7 lines) Barrel — public surface only. No export *, no internals. types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess @@ -84,7 +84,7 @@ test/ normal.test.ts (823) handleNormalKey branches visual.test.ts (287) handleVisualKey branches textobject.test.ts (64) resolveTextObject dispatch seam - integration.test.ts (418) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync + integration.test.ts (579) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking leader.test.ts (125 lines) Unit tests for leader key matching functions ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index aa15950..c028de6 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 + +- Dismissing or rejecting a question prompt no longer leaves vimcode stuck passing every key through to the editor (block cursor, typing works, Escape dead). vimcode now also listens for `question.rejected`, not just `question.replied` ([#72](https://github.com/oribarilan/vimcode/issues/72)). + ## [0.18.0] — 2026-09-02 ### Added diff --git a/src/index.ts b/src/index.ts index f5f6433..1e56199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,11 +67,17 @@ const plugin: TuiPluginModule = { 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)); + // Dismissing a question emits question.rejected, not question.replied. + // Without this the +1 from question.asked never balances and the plugin + // stays stuck passing every key through to the host. + // biome-ignore lint/suspicious/noExplicitAny: event shape is untyped in the plugin API + const unsubQuestRejected = api.event?.on?.("question.rejected", (e: any) => trackPromptEvent(e, -1)); api.lifecycle?.onDispose?.(() => { unsubPermsAsked?.(); unsubPermsReplied?.(); unsubQuestAsked?.(); unsubQuestReplied?.(); + unsubQuestRejected?.(); }); function hasActivePrompts(sid: string): boolean { diff --git a/test/integration.test.ts b/test/integration.test.ts index c109d0b..e208911 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -475,3 +475,105 @@ describe("arrow keys pass through the intercept", () => { expect(press("j")).toBe(true); }); }); + +// ── prompt overlay tracking (question.rejected leak) ────── + +describe("prompt overlay tracking", () => { + // The plugin tracks pending permission/question prompts via events so it can + // pass keys through while an overlay owns the keyboard. Every "asked" must be + // balanced by a terminal event, otherwise hasActivePrompts() stays true and + // the plugin is stuck passing all keys (including Escape) to the host. + async function setup() { + const plugin = (await import("../src/index")).default; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + const events = new Map void>(); + const sessions: Record = { root: {}, child: { parentID: "root" } }; + + 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: "session", params: { sessionID: "root" } } }, + state: { + session: { + get: (id: string) => sessions[id], + question: () => [], + permission: () => [], + }, + }, + event: { + on: (name: string, h: (e: unknown) => void) => { + events.set(name, h); + return () => events.delete(name); + }, + }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: mock API + await plugin.tui(api as any, { updateCheck: false } as any, undefined as any); + + const press = (name: string) => { + let consumed = false; + handler?.({ + event: { name, eventType: "press" }, + consume: () => { + consumed = true; + }, + }); + return consumed; + }; + + const emit = (name: string, properties: Record) => events.get(name)?.({ properties }); + + press("escape"); // leave insert, enter normal mode + return { press, emit }; + } + + it("keys pass through while a question is pending, then resume after question.rejected", async () => { + const { press, emit } = await setup(); + + emit("question.asked", { id: "q1", sessionID: "root" }); + expect(press("h")).toBe(false); + + emit("question.rejected", { requestID: "q1", sessionID: "root" }); + expect(press("h")).toBe(true); + }); + + it("question.replied also resumes key consumption", async () => { + const { press, emit } = await setup(); + + emit("question.asked", { id: "q1", sessionID: "root" }); + expect(press("h")).toBe(false); + + emit("question.replied", { requestID: "q1", sessionID: "root" }); + expect(press("h")).toBe(true); + }); + + it("permission.replied resumes key consumption", async () => { + const { press, emit } = await setup(); + + emit("permission.asked", { id: "p1", sessionID: "root" }); + expect(press("h")).toBe(false); + + emit("permission.replied", { requestID: "p1", sessionID: "root" }); + expect(press("h")).toBe(true); + }); + + it("a prompt on a child session is tracked against its root", async () => { + const { press, emit } = await setup(); + + emit("question.asked", { id: "q1", sessionID: "child" }); + expect(press("h")).toBe(false); + + emit("question.rejected", { requestID: "q1", sessionID: "child" }); + expect(press("h")).toBe(true); + }); +});