Something went wrong. Try again.
Some keybindings for navigation in opencode
Something went wrong. Try again.
7.6 kB · 224 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225import { 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<Record<string, unknown>>, 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([]); });});