From af2e0419d4aa0cac4c576b3f0da82b2b1f52bc1f Mon Sep 17 00:00:00 2001 From: BeyramTaglietti Date: Wed, 9 Sep 2026 16:15:26 +0200 Subject: [PATCH] fix: unstick keys after rejecting a question prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why vimcode tracks pending prompts via api.event so it can pass keys through while an overlay owns the keyboard. It subscribed to question.asked (+1) and question.replied (-1) only — not question.rejected, which the host emits when a question prompt is dismissed. The +1 never balanced, so hasActivePrompts() stayed true and the key intercept returned early without consuming any key. Every key, including Escape, went to the host while syncCursorStyle() kept painting the block cursor. ## How Subscribe to question.rejected and apply the same -1 as question.replied, disposing it alongside the other listeners. ## Tests Integration test drives plugin.tui with mock event handlers and asserts keys pass through while a question is pending and are consumed again after question.rejected. Also covers question.replied, permission.replied, and child-to-root resolution. bun test green (313 tests). Fixes #72 --- AGENTS.md | 4 +- CHANGELOG.md | 4 ++ src/index.ts | 6 +++ test/integration.test.ts | 102 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a286c8f..a4b9b09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,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 @@ -83,7 +83,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); + }); +});