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
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/tui/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}),
),
}
27 changes: 27 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,7 @@ export namespace Server {
return c.json(true)
},
)

.post(
"/session/:sessionID/share",
describeRoute({
Expand Down Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions packages/opencode/test/server/session-select.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
})
40 changes: 39 additions & 1 deletion packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
EventSubscribeResponses,
EventTuiCommandExecute,
EventTuiPromptAppend,
EventTuiSessionSelect,
EventTuiToastShow,
FileListResponses,
FilePartInput,
Expand Down Expand Up @@ -141,6 +142,8 @@ import type {
TuiOpenThemesResponses,
TuiPublishErrors,
TuiPublishResponses,
TuiSelectSessionErrors,
TuiSelectSessionResponses,
TuiShowToastResponses,
TuiSubmitPromptResponses,
VcsGetResponses,
Expand Down Expand Up @@ -2644,7 +2647,7 @@ export class Tui extends HeyApiClient {
public publish<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow
body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect
},
options?: Options<never, ThrowOnError>,
) {
Expand All @@ -2661,6 +2664,41 @@ export class Tui extends HeyApiClient {
})
}

/**
* Select session
*
* Navigate the TUI to display the specified session.
*/
public selectSession<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "body", key: "sessionID" },
],
},
],
)
return (options?.client ?? this.client).post<TuiSelectSessionResponses, TuiSelectSessionErrors, ThrowOnError>({
url: "/tui/select-session",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}

control = new Control({ client: this.client })
}

Expand Down
49 changes: 48 additions & 1 deletion packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -766,6 +776,7 @@ export type Event =
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventCommandExecuted
| EventSessionCreated
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down