From 5c12a25cf3714b3f6052be087b5564f5e2dea938 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 1 Jan 2026 17:14:17 +0900 Subject: [PATCH 1/3] feat(plugin): add session.select API endpoint for TUI navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add plugin API endpoint (POST /session/{sessionID}/select) that allows plugins to change the currently viewed session in the TUI. Implements SessionSelect event type and corresponding event handler in TUI app for seamless navigation. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- packages/opencode/src/cli/cmd/tui/app.tsx | 7 ++++ packages/opencode/src/cli/cmd/tui/event.ts | 6 +++ packages/opencode/src/server/server.ts | 31 +++++++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 35 +++++++++++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 46 +++++++++++++++++++++- 5 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8b7b68273ac..086bfdb0fd6 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -535,6 +535,13 @@ function App() { }) }) + sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { + route.navigate({ + type: "session", + sessionID: evt.properties.sessionID, + }) + }) + sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { route.navigate({ type: "home" }) diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index acaa38e80a2..7c75523c136 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -37,4 +37,10 @@ export const TuiEvent = { duration: z.number().default(5000).optional().describe("Duration in milliseconds"), }), ), + SessionSelect: BusEvent.define( + "tui.session.select", + z.object({ + sessionID: z.string().regex(/^ses/).describe("Session ID to navigate to"), + }), + ), } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f31b8ec44f5..ea14a71fe8b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -974,6 +974,37 @@ export namespace Server { return c.json(true) }, ) + .post( + "/session/:sessionID/select", + describeRoute({ + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + operationId: "session.select", + responses: { + 200: { + description: "Session selected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.get.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await Session.get(sessionID) + await Bus.publish(TuiEvent.SessionSelect, { sessionID }) + return c.json(true) + }, + ) .post( "/session/:sessionID/share", describeRoute({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b0610b64bc3..26a78f82046 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -19,6 +19,7 @@ import type { EventSubscribeResponses, EventTuiCommandExecute, EventTuiPromptAppend, + EventTuiSessionSelect, EventTuiToastShow, FileListResponses, FilePartInput, @@ -106,6 +107,8 @@ import type { SessionPromptResponses, SessionRevertErrors, SessionRevertResponses, + SessionSelectErrors, + SessionSelectResponses, SessionShareErrors, SessionShareResponses, SessionShellErrors, @@ -1043,6 +1046,36 @@ export class Session extends HeyApiClient { }) } + /** + * Select session + * + * Navigate the TUI to display the specified session. + */ + public select( + parameters: { + sessionID: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/select", + ...options, + ...params, + }) + } + /** * Unshare session * @@ -2644,7 +2677,7 @@ export class Tui extends HeyApiClient { public publish( parameters?: { directory?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 85a3c428625..74d0e6a5583 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -593,6 +593,16 @@ export type EventTuiToastShow = { } } +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type EventMcpToolsChanged = { type: "mcp.tools.changed" properties: { @@ -766,6 +776,7 @@ export type Event = | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventCommandExecuted | EventSessionCreated @@ -2780,6 +2791,39 @@ export type SessionAbortResponses = { export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] +export type SessionSelectData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + } + url: "/session/{sessionID}/select" +} + +export type SessionSelectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionSelectError = SessionSelectErrors[keyof SessionSelectErrors] + +export type SessionSelectResponses = { + /** + * Session selected successfully + */ + 200: boolean +} + +export type SessionSelectResponse = SessionSelectResponses[keyof SessionSelectResponses] + export type SessionUnshareData = { body?: never path: { @@ -4288,7 +4332,7 @@ export type TuiShowToastResponses = { export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect path?: never query?: { directory?: string From ac15b2d9bd08db7f5456528ca002e85e7bdabd3d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 1 Jan 2026 20:36:55 +0900 Subject: [PATCH 2/3] test(server): add session.select endpoint tests Add unit tests for POST /session/:sessionID/select endpoint: - 200 response for valid session ID - 404 response for non-existent session - 400 response for invalid session ID format GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- .../test/server/session-select.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 packages/opencode/test/server/session-select.test.ts diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts new file mode 100644 index 00000000000..6359c25e4f7 --- /dev/null +++ b/packages/opencode/test/server/session-select.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("session.select endpoint", () => { + test("should return 200 when called with valid session", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // #given + const session = await Session.create({}) + + // #when + const app = Server.App() + const response = await app.request(`/session/${session.id}/select`, { + method: "POST", + }) + + // #then + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toBe(true) + + await Session.remove(session.id) + }, + }) + }) + + test("should return 404 when session does not exist", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // #given + const nonExistentSessionID = "ses_nonexistent123" + + // #when + const app = Server.App() + const response = await app.request(`/session/${nonExistentSessionID}/select`, { + method: "POST", + }) + + // #then + expect(response.status).toBe(404) + }, + }) + }) + + test("should return 400 when session ID format is invalid", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // #given + const invalidSessionID = "invalid_session_id" + + // #when + const app = Server.App() + const response = await app.request(`/session/${invalidSessionID}/select`, { + method: "POST", + }) + + // #then + expect(response.status).toBe(400) + }, + }) + }) +}) From dc8c63d902cde173480ce5513ec2f5443774f463 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 2 Jan 2026 16:55:23 -0600 Subject: [PATCH 3/3] tweak: rename route to have /tui prefix --- packages/opencode/src/server/server.ts | 58 ++++++++-------- .../test/server/session-select.test.ts | 14 ++-- packages/sdk/js/src/v2/gen/sdk.gen.ts | 69 ++++++++++--------- packages/sdk/js/src/v2/gen/types.gen.ts | 69 ++++++++++--------- 4 files changed, 110 insertions(+), 100 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ea14a71fe8b..6e9d2531895 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -974,37 +974,7 @@ export namespace Server { return c.json(true) }, ) - .post( - "/session/:sessionID/select", - describeRoute({ - summary: "Select session", - description: "Navigate the TUI to display the specified session.", - operationId: "session.select", - responses: { - 200: { - description: "Session selected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.get.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.get(sessionID) - await Bus.publish(TuiEvent.SessionSelect, { sessionID }) - return c.json(true) - }, - ) + .post( "/session/:sessionID/share", describeRoute({ @@ -2598,6 +2568,32 @@ export namespace Server { return c.json(true) }, ) + .post( + "/tui/select-session", + describeRoute({ + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + operationId: "tui.selectSession", + responses: { + 200: { + description: "Session selected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", TuiEvent.SessionSelect.properties), + async (c) => { + const { sessionID } = c.req.valid("json") + await Session.get(sessionID) + await Bus.publish(TuiEvent.SessionSelect, { sessionID }) + return c.json(true) + }, + ) .route("/tui/control", TuiRoute) .put( "/auth/:providerID", diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 6359c25e4f7..479be4a17f8 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -8,7 +8,7 @@ import { Server } from "../../src/server/server" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) -describe("session.select endpoint", () => { +describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await Instance.provide({ directory: projectRoot, @@ -18,8 +18,10 @@ describe("session.select endpoint", () => { // #when const app = Server.App() - const response = await app.request(`/session/${session.id}/select`, { + const response = await app.request("/tui/select-session", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: session.id }), }) // #then @@ -41,8 +43,10 @@ describe("session.select endpoint", () => { // #when const app = Server.App() - const response = await app.request(`/session/${nonExistentSessionID}/select`, { + const response = await app.request("/tui/select-session", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: nonExistentSessionID }), }) // #then @@ -60,8 +64,10 @@ describe("session.select endpoint", () => { // #when const app = Server.App() - const response = await app.request(`/session/${invalidSessionID}/select`, { + const response = await app.request("/tui/select-session", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: invalidSessionID }), }) // #then diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 26a78f82046..5605edd3401 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -107,8 +107,6 @@ import type { SessionPromptResponses, SessionRevertErrors, SessionRevertResponses, - SessionSelectErrors, - SessionSelectResponses, SessionShareErrors, SessionShareResponses, SessionShellErrors, @@ -144,6 +142,8 @@ import type { TuiOpenThemesResponses, TuiPublishErrors, TuiPublishResponses, + TuiSelectSessionErrors, + TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, VcsGetResponses, @@ -1046,36 +1046,6 @@ export class Session extends HeyApiClient { }) } - /** - * Select session - * - * Navigate the TUI to display the specified session. - */ - public select( - parameters: { - sessionID: string - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/select", - ...options, - ...params, - }) - } - /** * Unshare session * @@ -2694,6 +2664,41 @@ export class Tui extends HeyApiClient { }) } + /** + * Select session + * + * Navigate the TUI to display the specified session. + */ + public selectSession( + parameters?: { + directory?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/select-session", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + control = new Control({ client: this.client }) } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 74d0e6a5583..c6d7ca0f731 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2791,39 +2791,6 @@ export type SessionAbortResponses = { export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] -export type SessionSelectData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - } - url: "/session/{sessionID}/select" -} - -export type SessionSelectErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionSelectError = SessionSelectErrors[keyof SessionSelectErrors] - -export type SessionSelectResponses = { - /** - * Session selected successfully - */ - 200: boolean -} - -export type SessionSelectResponse = SessionSelectResponses[keyof SessionSelectResponses] - export type SessionUnshareData = { body?: never path: { @@ -4358,6 +4325,42 @@ export type TuiPublishResponses = { export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses] +export type TuiSelectSessionData = { + body?: { + /** + * Session ID to navigate to + */ + sessionID: string + } + path?: never + query?: { + directory?: string + } + url: "/tui/select-session" +} + +export type TuiSelectSessionErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type TuiSelectSessionError = TuiSelectSessionErrors[keyof TuiSelectSessionErrors] + +export type TuiSelectSessionResponses = { + /** + * Session selected successfully + */ + 200: boolean +} + +export type TuiSelectSessionResponse = TuiSelectSessionResponses[keyof TuiSelectSessionResponses] + export type TuiControlNextData = { body?: never path?: never