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: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand Down
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

- 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
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
102 changes: 102 additions & 0 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (e: unknown) => void>();
const sessions: Record<string, { parentID?: string }> = { 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<string, unknown>) => 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);
});
});
Loading