From 8a5ada5a3a38ddd8b020434a4913536b7479671b Mon Sep 17 00:00:00 2001 From: Vladimir Ivakin Date: Wed, 5 Aug 2026 20:19:56 +0200 Subject: [PATCH 1/2] Add GitHub actions --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d6c4d9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run check From 70c535895d23c4e433bb60e979f1aaee50305abd Mon Sep 17 00:00:00 2001 From: Vladimir Ivakin Date: Wed, 5 Aug 2026 20:22:20 +0200 Subject: [PATCH 2/2] Added tab navigation commands and keybinding options --- CHANGELOG.md | 6 + README.md | 29 +++ src/session-tabs/SessionTabs.spec.tsx | 49 ++++- src/session-tabs/SessionTabs.tsx | 11 +- .../sessionTabsController.spec.ts | 175 ++++++++++++++++++ src/session-tabs/sessionTabsController.ts | 46 ++++- src/session-tabs/sessionTabsKeymap.spec.ts | 138 ++++++++++++++ src/session-tabs/sessionTabsKeymap.ts | 95 ++++++++++ src/tui.tsx | 7 +- 9 files changed, 544 insertions(+), 12 deletions(-) create mode 100644 src/session-tabs/sessionTabsKeymap.spec.ts create mode 100644 src/session-tabs/sessionTabsKeymap.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cdbadc..f4a1476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- Added tab navigation commands and keybinding options. + ## [0.1.0] - 2026-08-01 ### Added diff --git a/README.md b/README.md index 169bbb2..c30bc2c 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,32 @@ Or, add the package directly to `.opencode/tui.json`: ``` Restart OpenCode after the installation. + +## Keyboard navigation + +The `Next tab`, `Previous tab`, and `Close tab` commands are available in the +command palette. They have no keybindings by default. To open a new tab, use +OpenCode's built-in `New session` command. Check the +[OpenCode keybinds documentation](https://opencode.ai/docs/keybinds/). + +To enable keyboard navigation, configure the plugin with options: + +```json +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": [ + [ + "opencode-tabs", + { + "keybinds": { + "next": "]", + "previous": "[", + "close": "w" + } + } + ] + ] +} +``` + +Restart OpenCode after changing the configuration. diff --git a/src/session-tabs/SessionTabs.spec.tsx b/src/session-tabs/SessionTabs.spec.tsx index 1cc50fd..e09d791 100644 --- a/src/session-tabs/SessionTabs.spec.tsx +++ b/src/session-tabs/SessionTabs.spec.tsx @@ -17,9 +17,11 @@ const colors = {} as TuiThemeCurrent; function createApi({ tabs = [], projectCurrent = vi.fn(async () => ({ data: { id: "project-one" } })), + registerLayer = vi.fn(() => () => undefined), }: { tabs?: Tab[]; projectCurrent?: ReturnType; + registerLayer?: ReturnType; } = {}) { const key = tabStorageKey({ projectID: "project-one", path: "" }); const storage = new Map([[key, persistedTabs(tabs)]]); @@ -57,6 +59,7 @@ function createApi({ storage.set(storageKey, value), }, event: { on: () => () => undefined }, + keymap: { registerLayer }, route: { current: { name: "home" }, navigate: vi.fn() }, theme: { current: colors }, } as unknown as TuiPluginApi; @@ -71,14 +74,41 @@ const tab = (sessionID: string, title: string): Tab => ({ }); describe("SessionTabs rendering", () => { + it("unregisters its keymap layer on disposal", async () => { + const unregisterCommands = vi.fn(); + const unregisterBindings = vi.fn(); + const registerLayer = vi + .fn(() => () => undefined) + .mockReturnValueOnce(unregisterCommands) + .mockReturnValueOnce(unregisterBindings); + const api = createApi({ registerLayer }); + const setup = await testRender( + () => , + { + width: 40, + height: 4, + }, + ); + + await setup.renderOnce(); + expect(registerLayer).toHaveBeenCalledTimes(2); + + setup.renderer.destroy(); + expect(unregisterCommands).toHaveBeenCalledOnce(); + expect(unregisterBindings).toHaveBeenCalledOnce(); + }); + it("mounts a concrete root before persistence is available", async () => { const api = createApi({ projectCurrent: vi.fn(() => new Promise(() => undefined)), }); - const setup = await testRender(() => , { - width: 40, - height: 4, - }); + const setup = await testRender( + () => , + { + width: 40, + height: 4, + }, + ); try { await setup.renderOnce(); @@ -100,10 +130,13 @@ describe("SessionTabs rendering", () => { tab("four", "Four"), ], }); - const setup = await testRender(() => , { - width: 66, - height: 6, - }); + const setup = await testRender( + () => , + { + width: 66, + height: 6, + }, + ); try { const wideFrame = await setup.waitForFrame((frame) => diff --git a/src/session-tabs/SessionTabs.tsx b/src/session-tabs/SessionTabs.tsx index 4b1d285..5781b25 100644 --- a/src/session-tabs/SessionTabs.tsx +++ b/src/session-tabs/SessionTabs.tsx @@ -9,13 +9,22 @@ import { TABS_BOTTOM_MARGIN, } from "../tabsLayout.js"; import { createSessionTabsController } from "./sessionTabsController.js"; +import { + createSessionTabsKeymap, + type TabBinding, +} from "./sessionTabsKeymap.js"; type SessionTabsProps = { api: TuiPluginApi; + bindings: readonly TabBinding[]; }; export function SessionTabs(props: SessionTabsProps) { - const { tabs, activeRootID, close } = createSessionTabsController(props.api); + const controller = createSessionTabsController(props.api); + + createSessionTabsKeymap(props.api, props.bindings, controller); + + const { tabs, activeRootID, close } = controller; const [boxWidth, setBoxWidth] = createSignal(null); const tabWidth = createMemo(() => { const width = boxWidth(); diff --git a/src/session-tabs/sessionTabsController.spec.ts b/src/session-tabs/sessionTabsController.spec.ts index 7441c4b..75c7f7b 100644 --- a/src/session-tabs/sessionTabsController.spec.ts +++ b/src/session-tabs/sessionTabsController.spec.ts @@ -97,6 +97,181 @@ function deferred() { } describe("session tabs controller", () => { + it("selects an edge tab when no tab is active", async () => { + const key = tabStorageKey({ projectID: "project-one", path: "" }); + const storage = new Map([ + [ + key, + persistedTabs([ + { sessionID: "one", title: "One", lastActiveTs: 1 }, + { sessionID: "two", title: "Two", lastActiveTs: 2 }, + { sessionID: "three", title: "Three", lastActiveTs: 3 }, + ]), + ], + ]); + const { api, navigate } = createApi({ + localSessions: { + one: { id: "one", title: "One" }, + two: { id: "two", title: "Two" }, + three: { id: "three", title: "Three" }, + }, + storage, + }); + let controller!: ReturnType; + const dispose = createRoot((dispose) => { + controller = createSessionTabsController(api); + return dispose; + }); + + try { + await vi.waitFor(() => { + expect(controller.activeRootID()).toBeUndefined(); + expect(controller.tabs().map((item) => item.sessionID)).toEqual([ + "one", + "two", + "three", + ]); + }); + + expect(controller.next()).toBe(true); + expect(navigate).toHaveBeenLastCalledWith("session", { + sessionID: "one", + }); + + expect(controller.previous()).toBe(true); + expect(navigate).toHaveBeenLastCalledWith("session", { + sessionID: "three", + }); + } finally { + dispose(); + } + }); + + it("wraps next navigation from the last tab to the first", async () => { + const key = tabStorageKey({ projectID: "project-one", path: "" }); + const storage = new Map([ + [ + key, + persistedTabs([ + { sessionID: "one", title: "One", lastActiveTs: 1 }, + { sessionID: "two", title: "Two", lastActiveTs: 2 }, + { sessionID: "three", title: "Three", lastActiveTs: 3 }, + ]), + ], + ]); + const { api, navigate } = createApi({ + routeSessionID: "three", + localSessions: { + one: { id: "one", title: "One" }, + two: { id: "two", title: "Two" }, + three: { id: "three", title: "Three" }, + }, + storage, + }); + let controller!: ReturnType; + const dispose = createRoot((dispose) => { + controller = createSessionTabsController(api); + return dispose; + }); + + try { + await vi.waitFor(() => { + expect(controller.activeRootID()).toBe("three"); + expect(controller.tabs().map((item) => item.sessionID)).toEqual([ + "one", + "two", + "three", + ]); + }); + + expect(controller.next()).toBe(true); + expect(navigate).toHaveBeenCalledWith("session", { sessionID: "one" }); + } finally { + dispose(); + } + }); + + it("wraps previous navigation from the first tab to the last", async () => { + const key = tabStorageKey({ projectID: "project-one", path: "" }); + const storage = new Map([ + [ + key, + persistedTabs([ + { sessionID: "one", title: "One", lastActiveTs: 1 }, + { sessionID: "two", title: "Two", lastActiveTs: 2 }, + { sessionID: "three", title: "Three", lastActiveTs: 3 }, + ]), + ], + ]); + const { api, navigate } = createApi({ + routeSessionID: "one", + localSessions: { + one: { id: "one", title: "One" }, + two: { id: "two", title: "Two" }, + three: { id: "three", title: "Three" }, + }, + storage, + }); + let controller!: ReturnType; + const dispose = createRoot((dispose) => { + controller = createSessionTabsController(api); + return dispose; + }); + + try { + await vi.waitFor(() => { + expect(controller.activeRootID()).toBe("one"); + expect(controller.tabs().map((item) => item.sessionID)).toEqual([ + "one", + "two", + "three", + ]); + }); + + expect(controller.previous()).toBe(true); + expect(navigate).toHaveBeenCalledWith("session", { sessionID: "three" }); + } finally { + dispose(); + } + }); + + it("rejects navigation with one tab and closes the active visual tab", async () => { + const key = tabStorageKey({ projectID: "project-one", path: "" }); + const storage = new Map([ + [ + key, + persistedTabs([{ sessionID: "one", title: "One", lastActiveTs: 1 }]), + ], + ]); + const { api, navigate } = createApi({ + routeSessionID: "one", + localSessions: { one: { id: "one", title: "One" } }, + storage, + }); + let controller!: ReturnType; + const dispose = createRoot((dispose) => { + controller = createSessionTabsController(api); + return dispose; + }); + + try { + await vi.waitFor(() => { + expect(controller.activeRootID()).toBe("one"); + expect(controller.tabs()).toHaveLength(1); + }); + + expect(controller.next()).toBe(false); + expect(controller.previous()).toBe(false); + expect(navigate).not.toHaveBeenCalled(); + + expect(controller.closeActive()).toBe(true); + expect(controller.tabs()).toEqual([]); + expect(navigate).toHaveBeenCalledWith("home"); + } finally { + dispose(); + } + }); + it("maps a child route to one active root tab", async () => { const sessionGet = vi.fn(); const { api } = createApi({ diff --git a/src/session-tabs/sessionTabsController.ts b/src/session-tabs/sessionTabsController.ts index e5e8f09..f4e01ab 100644 --- a/src/session-tabs/sessionTabsController.ts +++ b/src/session-tabs/sessionTabsController.ts @@ -132,7 +132,51 @@ export function createSessionTabsController(api: TuiPluginApi) { }); }; - return { tabs, activeRootID, close }; + const navigateBy = (offset: -1 | 1) => { + const currentTabs = tabs(); + const currentActiveRootID = activeRootID(); + let targetIndex: number; + + if (currentActiveRootID === undefined) { + targetIndex = offset === 1 ? 0 : currentTabs.length - 1; + } else { + if (currentTabs.length < 2) return false; + + const currentIndex = currentTabs.findIndex( + (tab) => tab.sessionID === currentActiveRootID, + ); + if (currentIndex === -1) return false; + + targetIndex = + (currentIndex + offset + currentTabs.length) % currentTabs.length; + } + + const target = currentTabs[targetIndex]; + + if (!target) return false; + + api.route.navigate("session", { sessionID: target.sessionID }); + return true; + }; + + const closeActive = () => { + const sessionID = activeRootID(); + if (!sessionID || !tabs().some((tab) => tab.sessionID === sessionID)) { + return false; + } + + close(sessionID); + return true; + }; + + return { + tabs, + activeRootID, + close, + next: () => navigateBy(1), + previous: () => navigateBy(-1), + closeActive, + }; } async function resolveRootTab( diff --git a/src/session-tabs/sessionTabsKeymap.spec.ts b/src/session-tabs/sessionTabsKeymap.spec.ts new file mode 100644 index 0000000..70b1d9b --- /dev/null +++ b/src/session-tabs/sessionTabsKeymap.spec.ts @@ -0,0 +1,138 @@ +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { createRoot } from "solid-js"; +import { describe, expect, it, vi } from "vitest"; + +import { + createSessionTabsKeymap, + resolveTabBindings, +} from "./sessionTabsKeymap.js"; + +vi.mock("solid-js", async () => { + // @ts-expect-error Solid does not publish declarations for this runtime entry. + return import("solid-js/dist/solid.js"); +}); + +const commandIDs = { + next: "opencode-tabs.next", + previous: "opencode-tabs.previous", + close: "opencode-tabs.close", +} as const; + +describe("session tabs keymap", () => { + it("has no bindings by default", () => { + expect(resolveTabBindings(undefined)).toEqual([]); + }); + + it("maps configured aliases and ignores unrelated bindings", () => { + expect( + resolveTabBindings({ + keybinds: { + next: ["]", "ctrl+tab"], + previous: "[", + close: { key: "w", preventDefault: false }, + unrelated: "ctrl+x", + }, + }), + ).toEqual([ + { key: "]", cmd: commandIDs.next, desc: "Next tab" }, + { key: "ctrl+tab", cmd: commandIDs.next, desc: "Next tab" }, + { key: "[", cmd: commandIDs.previous, desc: "Previous tab" }, + { + key: "w", + cmd: commandIDs.close, + desc: "Close tab", + preventDefault: false, + }, + ]); + }); + + it("accepts explicit disabled bindings", () => { + expect( + resolveTabBindings({ keybinds: { next: false, previous: "none" } }), + ).toEqual([]); + }); + + it("registers palette commands and returns their action results", () => { + const unregisterCommands = vi.fn(); + const unregisterBindings = vi.fn(); + const registerLayer = vi + .fn((_layer: unknown) => () => undefined) + .mockReturnValueOnce(unregisterCommands) + .mockReturnValueOnce(unregisterBindings); + const api = { + keymap: { registerLayer }, + } as unknown as TuiPluginApi; + const actions = { + next: vi.fn(() => true), + previous: vi.fn(() => false), + closeActive: vi.fn(() => true), + }; + const bindings = [{ key: "ctrl+w", cmd: commandIDs.close }]; + + const dispose = createRoot((dispose) => { + createSessionTabsKeymap(api, bindings, actions); + return dispose; + }); + + expect(registerLayer).toHaveBeenCalledTimes(2); + const commandLayer = registerLayer.mock.calls[0]?.[0] as { + commands: Array<{ + name: string; + title: string; + desc: string; + category: string; + namespace: string; + run: (context: unknown) => boolean; + }>; + }; + expect(commandLayer).not.toHaveProperty("mode"); + expect( + commandLayer.commands.map( + ({ name, title, desc, category, namespace }) => ({ + name, + title, + desc, + category, + namespace, + }), + ), + ).toEqual([ + { + name: commandIDs.next, + title: "Next tab", + desc: "Switch to the next tab", + category: "Tabs", + namespace: "palette", + }, + { + name: commandIDs.previous, + title: "Previous tab", + desc: "Switch to the previous tab", + category: "Tabs", + namespace: "palette", + }, + { + name: commandIDs.close, + title: "Close tab", + desc: "Close the active tab", + category: "Tabs", + namespace: "palette", + }, + ]); + expect(registerLayer.mock.calls[1]?.[0]).toEqual({ + mode: "base", + bindings, + }); + + expect(commandLayer.commands[0]?.run({})).toBe(true); + expect(commandLayer.commands[1]?.run({})).toBe(false); + expect(commandLayer.commands[2]?.run({})).toBe(true); + expect(actions.next).toHaveBeenCalledOnce(); + expect(actions.previous).toHaveBeenCalledOnce(); + expect(actions.closeActive).toHaveBeenCalledOnce(); + + dispose(); + expect(unregisterCommands).toHaveBeenCalledOnce(); + expect(unregisterBindings).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/session-tabs/sessionTabsKeymap.ts b/src/session-tabs/sessionTabsKeymap.ts new file mode 100644 index 0000000..7238d5b --- /dev/null +++ b/src/session-tabs/sessionTabsKeymap.ts @@ -0,0 +1,95 @@ +import type { PluginOptions } from "@opencode-ai/plugin"; +import { + createBindingLookup, + type TuiPluginApi, +} from "@opencode-ai/plugin/tui"; +import { onCleanup } from "solid-js"; +import { PROJECT_NAME } from "../consts.js"; + +export type TabBinding = ReturnType< + typeof createBindingLookup +>["bindings"][number]; +type TabBindingConfig = NonNullable[0]>; + +type SessionTabsActions = { + next: () => boolean; + previous: () => boolean; + closeActive: () => boolean; +}; + +type CommandDefinition = { + alias: string; + action: keyof SessionTabsActions; + name: string; + title: string; + desc: string; +}; + +const commandDefinitions: CommandDefinition[] = [ + { + alias: "next", + action: "next", + name: `${PROJECT_NAME}.next`, + title: "Next tab", + desc: "Switch to the next tab", + }, + { + alias: "previous", + action: "previous", + name: `${PROJECT_NAME}.previous`, + title: "Previous tab", + desc: "Switch to the previous tab", + }, + { + alias: "close", + action: "closeActive", + name: `${PROJECT_NAME}.close`, + title: "Close tab", + desc: "Close the active tab", + }, +]; + +const commandMap = Object.fromEntries( + commandDefinitions.map(({ alias, name }) => [alias, name]), +); +const commandNames = commandDefinitions.map(({ name }) => name); + +export function resolveTabBindings( + options: PluginOptions | undefined, +): readonly TabBinding[] { + const config = options?.keybinds; + if (!config || typeof config !== "object" || Array.isArray(config)) return []; + + return createBindingLookup(config as TabBindingConfig, { + commandMap, + bindingDefaults: ({ command }) => ({ + desc: commandDefinitions.find(({ name }) => name === command)?.title, + }), + }).gather(PROJECT_NAME, commandNames); +} + +export function createSessionTabsKeymap( + api: TuiPluginApi, + bindings: readonly TabBinding[], + actions: SessionTabsActions, +) { + const unregisterCommands = api.keymap.registerLayer({ + commands: commandDefinitions.map((command) => ({ + name: command.name, + title: command.title, + desc: command.desc, + category: "Tabs", + namespace: "palette", + run: actions[command.action], + })), + }); + const unregisterBindings = api.keymap.registerLayer({ + mode: "base", + bindings, + }); + + onCleanup(() => { + unregisterBindings(); + unregisterCommands(); + }); +} diff --git a/src/tui.tsx b/src/tui.tsx index a695b35..073530f 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,14 +1,17 @@ import type { TuiPluginModule } from "@opencode-ai/plugin/tui"; import { PROJECT_NAME } from "./consts.js"; import { SessionTabs } from "./session-tabs/SessionTabs.jsx"; +import { resolveTabBindings } from "./session-tabs/sessionTabsKeymap.js"; const plugin: TuiPluginModule = { id: PROJECT_NAME, - tui: async (api) => { + tui: async (api, options) => { + const bindings = resolveTabBindings(options); + api.slots.register({ slots: { app_bottom() { - return ; + return ; }, }, });