import { describe, expect, it } from "vitest"; import { COMMAND_IDS } from "../src/tui/commands.ts"; import type { KeymapCommand, KeymapLayer, SlotClaim, TabInfo, Tabs, TuiContext } from "../src/tui/context.ts"; import plugin, { MOVE_UNAVAILABLE_TOAST, PLUGIN_ID, setup, setupWithOptions } from "../src/tui/plugin.ts"; interface ToastShown { readonly title?: string; readonly message: string; readonly variant?: string; } interface Harness { readonly context: TuiContext; readonly claims: SlotClaim<"app">[]; readonly layers: KeymapLayer[]; readonly dispatches: string[]; readonly toasts: ToastShown[]; disposed: boolean; } /** The published upstream contract — including the ABSOLUTE move(sessionID, index). */ function baseTabs(absoluteMoves: Array<[string, number]> = []): Tabs { return { enabled: () => true, list: (): readonly TabInfo[] => [], open: () => true, focus: () => false, move: (sessionID: string, index: number) => { absoluteMoves.push([sessionID, index]); return true; }, close: () => false, }; } /** plugact extends the published contract with the directional moveBy. */ function plugactTabs(moves: Array<[string | undefined, 1 | -1]>): Tabs { return { ...baseTabs(), moveBy: (sessionID?: string | 1 | -1, direction?: 1 | -1) => { moves.push([typeof sessionID === "string" ? sessionID : undefined, direction ?? 1]); return true; }, }; } function harness(options: Readonly>, tabs?: Tabs): Harness { const claims: SlotClaim<"app">[] = []; const layers: KeymapLayer[] = []; const dispatches: string[] = []; const toasts: ToastShown[] = []; const harness: Harness = { claims, layers, dispatches, toasts, disposed: false, context: { options, keymap: { layer: (input) => { layers.push(input()); }, dispatch: (id) => { dispatches.push(id); }, }, ui: { toast: { show: (shown) => { toasts.push(shown); }, }, tabs: tabs ?? baseTabs(), slot: (claim) => { claims.push(claim as SlotClaim<"app">); return () => { harness.disposed = true; }; }, }, }, }; return harness; } /** Run setup and flush the app claim's render, returning the cleanup. */ function activate(harness: Harness): () => void { const cleanup = setup(harness.context); if (cleanup === undefined) throw new Error("setup registered nothing"); const claim = harness.claims[0]; if (claim === undefined) throw new Error("no slot claim"); expect(claim.render({})).toBeNull(); return cleanup; } function command(harness: Harness, id: string): KeymapCommand { const commands = harness.layers[0]?.commands ?? []; const command = commands.find((command) => command.id === id); if (command === undefined) throw new Error(`no command ${id}`); return command; } describe("plugin registration", () => { it("exports the plugin shape with a stable id", () => { expect(plugin.id).toBe(PLUGIN_ID); expect(plugin.id).toBe("opencode-vimnav"); expect(plugin.setup).toBe(setup); }); it("claims app (append) and renders null; re-render never duplicates the layer", () => { const h = harness({}); activate(h); expect(h.claims[0]?.append).toBe("app"); expect(h.claims[0]?.render({})).toBeNull(); expect(h.layers).toHaveLength(1); expect(h.layers[0]?.mode).toBe("global"); }); it("cleanup disposes the claim", () => { const h = harness({}); const cleanup = activate(h); expect(h.disposed).toBe(false); cleanup(); expect(h.disposed).toBe(true); }); it("registers nothing when disabled", () => { const h = harness({ enabled: false }); expect(setup(h.context)).toBeUndefined(); expect(h.claims).toHaveLength(0); }); it("propagates option failures out of setup", () => { const h = harness({ binds: { previous: "alt+j" }, turbo: true }); expect(() => setup(h.context)).toThrow(/unknown option\(s\) turbo/); }); it("setup uses context.options; setupWithOptions is the explicit seam", () => { const h = harness({ binds: { next: "alt+k" } }); setupWithOptions(h.context, { binds: { next: "alt+k" } }); h.claims[0]?.render({}); expect(command(h, COMMAND_IDS.next).bind).toBe("alt+k"); }); }); describe("switch commands", () => { it("dispatch the built-ins, with default binds", () => { const h = harness({}); activate(h); expect(command(h, COMMAND_IDS.previous).bind).toBe("alt+h"); expect(command(h, COMMAND_IDS.next).bind).toBe("alt+l"); command(h, COMMAND_IDS.previous).run(); command(h, COMMAND_IDS.next).run(); expect(h.dispatches).toEqual(["session.tab.previous", "session.tab.next"]); }); }); describe("move commands with plugact (tabs.moveBy present)", () => { it("bind and move the active tab in the right direction", () => { const moves: Array<[string | undefined, 1 | -1]> = []; const h = harness({}, plugactTabs(moves)); activate(h); expect(command(h, COMMAND_IDS.previous).bind).toBe("alt+h"); expect(command(h, COMMAND_IDS.next).bind).toBe("alt+l"); expect(command(h, COMMAND_IDS.moveLeft).bind).toBe("alt+shift+h"); expect(command(h, COMMAND_IDS.moveRight).bind).toBe("alt+shift+l"); command(h, COMMAND_IDS.moveLeft).run(); command(h, COMMAND_IDS.moveRight).run(); expect(moves).toEqual([ [undefined, -1], [undefined, 1], ]); expect(h.toasts).toHaveLength(0); }); it("wire user-overridden move binds", () => { const h = harness({ binds: { moveLeft: "alt+shift+y", moveRight: "alt+shift+o" } }, plugactTabs([])); activate(h); expect(command(h, COMMAND_IDS.moveLeft).bind).toBe("alt+shift+y"); expect(command(h, COMMAND_IDS.moveRight).bind).toBe("alt+shift+o"); }); }); describe("move commands without plugact (feature-detect branch)", () => { it("stay palette-visible but unbound regardless of configured binds", () => { const h = harness({ binds: { previous: "alt+h,alt+up,ctrl+[", next: "alt+l,alt+down,ctrl+]", moveLeft: "alt+shift+h", moveRight: "alt+shift+l", }, }); activate(h); expect(command(h, COMMAND_IDS.moveLeft).palette).toBe(true); expect(command(h, COMMAND_IDS.moveLeft).bind).toBe(false); expect(command(h, COMMAND_IDS.moveRight).bind).toBe(false); // switching still fully works expect(command(h, COMMAND_IDS.previous).bind).toBe("alt+h,alt+up,ctrl+["); }); it("toast the capability absence instead of crashing on run", () => { const h = harness({}); activate(h); command(h, COMMAND_IDS.moveLeft).run(); expect(h.toasts).toEqual([{ title: "vimnav", message: MOVE_UNAVAILABLE_TOAST, variant: "info" }]); expect(h.dispatches).toHaveLength(0); }); it("upstream absolute move alone does not enable move keys (regression)", () => { // v2@origin ships tabs.move(sessionID, index); keying detection on `move` // would bind move keys that silently no-op against that signature. const absoluteMoves: Array<[string, number]> = []; const h = harness({}, baseTabs(absoluteMoves)); activate(h); expect(command(h, COMMAND_IDS.moveLeft).bind).toBe(false); expect(command(h, COMMAND_IDS.moveRight).bind).toBe(false); command(h, COMMAND_IDS.moveLeft).run(); expect(h.toasts).toHaveLength(1); // the upstream absolute move must never have been invoked expect(absoluteMoves).toEqual([]); }); });