From be2582f316e9bbb7ebba4e09091499868d42853d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:14:33 +0200 Subject: [PATCH] feat(plugin): decompose tab controls (#48129) Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com> Co-authored-by: Victor Navarro --- packages/plugin/src/tui/context.ts | 6 ++++-- packages/tui/src/context/session-tabs.tsx | 9 +++++++++ packages/tui/src/plugin/api.tsx | 10 ++++++++-- packages/tui/test/context/session-tabs.test.tsx | 17 +++++++++++++++++ .../www/src/docs/content/build/plugins/cli.mdx | 8 +++++--- 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 365a07d7f28..a149a0ff6e1 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -487,10 +487,12 @@ export interface UI { readonly attention: boolean readonly unread?: "activity" | "error" }[] - /** Opens (or focuses) a tab for a session, adding it when not already open. Returns false when tabs are disabled. */ + /** Opens a tab for a session without focusing it. Returns false when tabs are disabled. */ open(sessionID: string): boolean - /** Focuses an already-open tab and returns false when it is not open. */ + /** Opens a tab when needed, then focuses it. Returns false when tabs are disabled. */ focus(sessionID: string): boolean + /** Moves an open tab to an index and returns false when it is not open. */ + move(sessionID: string, index: number): boolean /** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */ close(sessionID?: string): boolean } diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 0f4f477a5a1..45d90eb9a9f 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -395,6 +395,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (!enabled()) return route.navigate({ type: "session", sessionID: root(sessionID) }) }, + open(sessionID: string) { + if (!enabled()) return + const session = root(sessionID) + if (state().tabs.some((tab) => tab.sessionID === session)) return + cancelledTabs.delete(session) + update((draft) => { + draft.tabs = openSessionTab(draft.tabs, { sessionID: session, title: title(session) }) + }) + }, promote(sessionID: string) { if (!enabled()) return const session = root(sessionID) diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx index 61211ab5340..38b101a1134 100644 --- a/packages/tui/src/plugin/api.tsx +++ b/packages/tui/src/plugin/api.tsx @@ -206,15 +206,21 @@ export function createPluginContext(input: { }), open(sessionID) { if (!host.sessionTabs.enabled()) return false - host.sessionTabs.select(sessionID) + host.sessionTabs.open(sessionID) return true }, focus(sessionID) { if (!host.sessionTabs.enabled()) return false - if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false host.sessionTabs.select(sessionID) return true }, + move(sessionID, index) { + if (!host.sessionTabs.enabled()) return false + const target = host.data.session.root(sessionID) + if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false + host.sessionTabs.move(target, index) + return true + }, close(sessionID) { if (!host.sessionTabs.enabled()) return false const target = sessionID ?? host.sessionTabs.current() diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index a259c5b782d..97a20cd6807 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -265,6 +265,23 @@ test("loads VCS metadata for each persisted tab location", async () => { } }) +test("opens a background tab without changing the current session", async () => { + const setup = await renderSessionTabs("first") + + try { + await wait(() => setup.tabs.current() === "first" && setup.tabs.tabs().some((tab) => tab.sessionID === "first")) + setup.tabs.open("background") + await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background")) + + expect(setup.tabs.current()).toBe("first") + expect(setup.tabs.isPreview("background")).toBe(false) + setup.tabs.move("background", 0) + await wait(() => setup.tabs.tabs()[0]?.sessionID === "background") + } finally { + await setup.destroy() + } +}) + test("loads location metadata when an open session moves", async () => { const destination = `${directory}/moved-worktree` const setup = await renderSessionTabs("first") diff --git a/services/www/src/docs/content/build/plugins/cli.mdx b/services/www/src/docs/content/build/plugins/cli.mdx index 4170aeace6b..6c5f6eda8b1 100644 --- a/services/www/src/docs/content/build/plugins/cli.mdx +++ b/services/www/src/docs/content/build/plugins/cli.mdx @@ -375,13 +375,15 @@ context.ui.router.navigate({ type: "home" }) return unregister ``` -Tabs can be listed, opened, focused, and closed when session tabs are enabled. +Tabs can be listed, opened, focused, moved, and closed when session tabs are enabled. `open` leaves focus unchanged; +`focus` opens the tab when needed. ```ts if (context.ui.tabs.enabled()) { - context.ui.tabs.open(sessionID) - const tabs = context.ui.tabs.list() + context.ui.tabs.open(backgroundSessionID) context.ui.tabs.focus(sessionID) + const tabs = context.ui.tabs.list() + context.ui.tabs.move(backgroundSessionID, tabs.length - 1) context.ui.tabs.close(sessionID) context.ui.tabs.close() }