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..6e9d2531895 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -974,6 +974,7 @@ export namespace Server { return c.json(true) }, ) + .post( "/session/:sessionID/share", describeRoute({ @@ -2567,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 new file mode 100644 index 00000000000..479be4a17f8 --- /dev/null +++ b/packages/opencode/test/server/session-select.test.ts @@ -0,0 +1,78 @@ +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("tui.selectSession 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("/tui/select-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: session.id }), + }) + + // #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("/tui/select-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: nonExistentSessionID }), + }) + + // #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("/tui/select-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: invalidSessionID }), + }) + + // #then + expect(response.status).toBe(400) + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b0610b64bc3..5605edd3401 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, @@ -141,6 +142,8 @@ import type { TuiOpenThemesResponses, TuiPublishErrors, TuiPublishResponses, + TuiSelectSessionErrors, + TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, VcsGetResponses, @@ -2644,7 +2647,7 @@ export class Tui extends HeyApiClient { public publish( parameters?: { directory?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect }, options?: Options, ) { @@ -2661,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 85a3c428625..c6d7ca0f731 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 @@ -4288,7 +4299,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 @@ -4314,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