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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Added

- Added tab navigation commands and keybinding options.

## [0.1.0] - 2026-08-01

### Added
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<leader>]",
"previous": "<leader>[",
"close": "<leader>w"
}
}
]
]
}
```

Restart OpenCode after changing the configuration.
49 changes: 41 additions & 8 deletions src/session-tabs/SessionTabs.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;
registerLayer?: ReturnType<typeof vi.fn>;
} = {}) {
const key = tabStorageKey({ projectID: "project-one", path: "" });
const storage = new Map<string, unknown>([[key, persistedTabs(tabs)]]);
Expand Down Expand Up @@ -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;
Expand All @@ -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(
() => <SessionTabs api={api} bindings={[]} />,
{
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<never>(() => undefined)),
});
const setup = await testRender(() => <SessionTabs api={api} />, {
width: 40,
height: 4,
});
const setup = await testRender(
() => <SessionTabs api={api} bindings={[]} />,
{
width: 40,
height: 4,
},
);

try {
await setup.renderOnce();
Expand All @@ -100,10 +130,13 @@ describe("SessionTabs rendering", () => {
tab("four", "Four"),
],
});
const setup = await testRender(() => <SessionTabs api={api} />, {
width: 66,
height: 6,
});
const setup = await testRender(
() => <SessionTabs api={api} bindings={[]} />,
{
width: 66,
height: 6,
},
);

try {
const wideFrame = await setup.waitForFrame((frame) =>
Expand Down
11 changes: 10 additions & 1 deletion src/session-tabs/SessionTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);
const tabWidth = createMemo(() => {
const width = boxWidth();
Expand Down
175 changes: 175 additions & 0 deletions src/session-tabs/sessionTabsController.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,181 @@ function deferred<T>() {
}

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<string, unknown>([
[
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<typeof createSessionTabsController>;
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<string, unknown>([
[
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<typeof createSessionTabsController>;
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<string, unknown>([
[
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<typeof createSessionTabsController>;
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<string, unknown>([
[
key,
persistedTabs([{ sessionID: "one", title: "One", lastActiveTs: 1 }]),
],
]);
const { api, navigate } = createApi({
routeSessionID: "one",
localSessions: { one: { id: "one", title: "One" } },
storage,
});
let controller!: ReturnType<typeof createSessionTabsController>;
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({
Expand Down
Loading