diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index bbfc7bade4f3..791aa0e28f79 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -329,19 +329,19 @@ This checklist tracks bridge parity only. Checked routes are available through t ### TUI Routes -- [ ] `POST /tui/append-prompt` - append prompt. -- [ ] `POST /tui/open-help` - open help. -- [ ] `POST /tui/open-sessions` - open sessions. -- [ ] `POST /tui/open-themes` - open themes. -- [ ] `POST /tui/open-models` - open models. -- [ ] `POST /tui/submit-prompt` - submit prompt. -- [ ] `POST /tui/clear-prompt` - clear prompt. -- [ ] `POST /tui/execute-command` - execute command. -- [ ] `POST /tui/show-toast` - show toast. -- [ ] `POST /tui/publish` - publish TUI event. -- [ ] `POST /tui/select-session` - select session. -- [ ] `GET /tui/control/next` - get next TUI request. -- [ ] `POST /tui/control/response` - submit TUI control response. +- [x] `POST /tui/append-prompt` - append prompt. +- [x] `POST /tui/open-help` - open help. +- [x] `POST /tui/open-sessions` - open sessions. +- [x] `POST /tui/open-themes` - open themes. +- [x] `POST /tui/open-models` - open models. +- [x] `POST /tui/submit-prompt` - submit prompt. +- [x] `POST /tui/clear-prompt` - clear prompt. +- [x] `POST /tui/execute-command` - execute command. +- [x] `POST /tui/show-toast` - show toast. +- [x] `POST /tui/publish` - publish TUI event. +- [x] `POST /tui/select-session` - select session. +- [x] `GET /tui/control/next` - get next TUI request. +- [x] `POST /tui/control/response` - submit TUI control response. ## Remaining PR Plan @@ -358,8 +358,8 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev 9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort. 10. [x] Bridge remaining session mutation and prompt routes. 11. [ ] Replace event SSE with non-Hono Effect HTTP. -12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP. -13. [ ] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer. +12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP. +13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer. 14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. 15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files. diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 719f5801b9da..0501ce5af252 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,12 +1,14 @@ import { Effect, Layer, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" +import { Bus } from "@/bus" import { AppRuntime } from "@/effect/app-runtime" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { Observability } from "@/effect" import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { Pty } from "@/pty" +import { Session } from "@/session" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util" import { authorizationLayer } from "./auth" @@ -23,6 +25,7 @@ import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" import { SessionApi, sessionHandlers } from "./session" import { SyncApi, syncHandlers } from "./sync" +import { TuiApi, tuiHandlers } from "./tui" import { WorkspaceApi, workspaceHandlers } from "./workspace" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" @@ -83,6 +86,11 @@ export const routes = Layer.mergeAll( HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)), HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)), HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)), + HttpApiBuilder.layer(TuiApi).pipe( + Layer.provide(tuiHandlers), + Layer.provide(Session.defaultLayer), + Layer.provide(Bus.layer), + ), HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)), ).pipe( Layer.provide(authorizationLayer), diff --git a/packages/opencode/src/server/routes/instance/httpapi/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/tui.ts new file mode 100644 index 000000000000..55f53df98491 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/tui.ts @@ -0,0 +1,286 @@ +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { nextTuiRequest, submitTuiResponse } from "../tui" +import { Authorization } from "./auth" + +const root = "/tui" +const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" }) +const TuiRequestPayload = Schema.Struct({ + path: Schema.String, + body: Schema.Unknown, +}).annotate({ identifier: "TuiRequest" }) +const TuiPublishPayload = Schema.Union([ + Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }), + Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }), + Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }), + Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }), +]).annotate({ identifier: "TuiEventInput" }) + +const commandAliases = { + session_new: "session.new", + session_share: "session.share", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + agent_cycle: "agent.cycle", +} as const + +export const TuiPaths = { + appendPrompt: `${root}/append-prompt`, + openHelp: `${root}/open-help`, + openSessions: `${root}/open-sessions`, + openThemes: `${root}/open-themes`, + openModels: `${root}/open-models`, + submitPrompt: `${root}/submit-prompt`, + clearPrompt: `${root}/clear-prompt`, + executeCommand: `${root}/execute-command`, + showToast: `${root}/show-toast`, + publish: `${root}/publish`, + selectSession: `${root}/select-session`, + controlNext: `${root}/control/next`, + controlResponse: `${root}/control/response`, +} as const + +export const TuiApi = HttpApi.make("tui") + .add( + HttpApiGroup.make("tui") + .add( + HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + payload: TuiEvent.PromptAppend.properties, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.appendPrompt", + summary: "Append TUI prompt", + description: "Append prompt to the TUI.", + }), + ), + HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openHelp", + summary: "Open help dialog", + description: "Open the help dialog in the TUI to display user assistance information.", + }), + ), + HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openSessions", + summary: "Open sessions dialog", + description: "Open the session dialog.", + }), + ), + HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openThemes", + summary: "Open themes dialog", + description: "Open the theme dialog.", + }), + ), + HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openModels", + summary: "Open models dialog", + description: "Open the model dialog.", + }), + ), + HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.submitPrompt", + summary: "Submit TUI prompt", + description: "Submit the prompt.", + }), + ), + HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.clearPrompt", + summary: "Clear TUI prompt", + description: "Clear the prompt.", + }), + ), + HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + payload: CommandPayload, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.executeCommand", + summary: "Execute TUI command", + description: "Execute a TUI command.", + }), + ), + HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + payload: TuiEvent.ToastShow.properties, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.showToast", + summary: "Show TUI toast", + description: "Show a toast notification in the TUI.", + }), + ), + HttpApiEndpoint.post("publish", TuiPaths.publish, { + payload: TuiPublishPayload, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.publish", + summary: "Publish TUI event", + description: "Publish a TUI event.", + }), + ), + HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + payload: TuiEvent.SessionSelect.properties, + success: Schema.Boolean, + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.selectSession", + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + }), + ), + HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.control.next", + summary: "Get next TUI request", + description: "Retrieve the next TUI request from the queue for processing.", + }), + ), + HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + payload: Schema.Unknown, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.control.response", + summary: "Submit TUI response", + description: "Submit a response to the TUI request queue to complete a pending request.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." })) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const tuiHandlers = Layer.unwrap( + Effect.gen(function* () { + const bus = yield* Bus.Service + const session = yield* Session.Service + const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => + bus.publish(TuiEvent.CommandExecute, { command }) + + const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { + payload: typeof TuiEvent.PromptAppend.properties.Type + }) { + yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) + return true + }) + + const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () { + yield* publishCommand("help.show") + return true + }) + + const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () { + yield* publishCommand("session.list") + return true + }) + + const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () { + yield* publishCommand("session.list") + return true + }) + + const openModels = Effect.fn("TuiHttpApi.openModels")(function* () { + yield* publishCommand("model.list") + return true + }) + + const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () { + yield* publishCommand("prompt.submit") + return true + }) + + const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () { + yield* publishCommand("prompt.clear") + return true + }) + + const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { + payload: typeof CommandPayload.Type + }) { + yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) + return true + }) + + const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { + payload: typeof TuiEvent.ToastShow.properties.Type + }) { + yield* bus.publish(TuiEvent.ToastShow, ctx.payload) + return true + }) + + const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { + if (ctx.payload.type === TuiEvent.PromptAppend.type) + yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.CommandExecute.type) + yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.SessionSelect.type) + yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) + return true + }) + + const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { + payload: typeof TuiEvent.SessionSelect.properties.Type + }) { + yield* session + .get(ctx.payload.sessionID) + .pipe(Effect.catchCause(() => Effect.fail(new HttpApiError.NotFound({})))) + yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) + return true + }) + + const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () { + return yield* Effect.promise(() => nextTuiRequest()) + }) + + const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) { + submitTuiResponse(ctx.payload) + return true + }) + + return HttpApiBuilder.group(TuiApi, "tui", (handlers) => + handlers + .handle("appendPrompt", appendPrompt) + .handle("openHelp", openHelp) + .handle("openSessions", openSessions) + .handle("openThemes", openThemes) + .handle("openModels", openModels) + .handle("submitPrompt", submitPrompt) + .handle("clearPrompt", clearPrompt) + .handle("executeCommand", executeCommand) + .handle("showToast", showToast) + .handle("publish", publish) + .handle("selectSession", selectSession) + .handle("controlNext", controlNext) + .handle("controlResponse", controlResponse), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index f0bd3f842886..90151c9a812b 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -24,6 +24,7 @@ import { InstancePaths } from "./httpapi/instance" import { McpPaths } from "./httpapi/mcp" import { SessionPaths } from "./httpapi/session" import { SyncPaths } from "./httpapi/sync" +import { TuiPaths } from "./httpapi/tui" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" import { PtyRoutes } from "./pty" @@ -130,6 +131,19 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) + app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 932cf509eb73..2fd931112a3b 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -12,15 +12,23 @@ import { errors } from "../../error" import { lazy } from "@/util/lazy" import { runRequest } from "./trace" -const TuiRequest = z.object({ +export const TuiRequest = z.object({ path: z.string(), body: z.any(), }) -type TuiRequest = z.infer +export type TuiRequest = z.infer const request = new AsyncQueue() -const response = new AsyncQueue() +const response = new AsyncQueue() + +export function nextTuiRequest() { + return request.next() +} + +export function submitTuiResponse(body: unknown) { + response.push(body) +} export async function callTui(ctx: Context) { const body = await ctx.req.json() @@ -50,7 +58,7 @@ const TuiControlRoutes = new Hono() }, }), async (c) => { - const req = await request.next() + const req = await nextTuiRequest() return c.json(req) }, ) @@ -74,7 +82,7 @@ const TuiControlRoutes = new Hono() validator("json", z.any()), async (c) => { const body = c.req.valid("json") - response.push(body) + submitTuiResponse(body) return c.json(true) }, ) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts new file mode 100644 index 000000000000..f5dac5ab4c98 --- /dev/null +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test } from "bun:test" +import type { Context } from "hono" +import type { UpgradeWebSocket } from "hono/ws" +import { Flag } from "@opencode-ai/core/flag/flag" +import { SessionID } from "../../src/session/schema" +import { Instance } from "../../src/project/instance" +import { InstanceRoutes } from "../../src/server/routes/instance" +import { TuiPaths } from "../../src/server/routes/instance/httpapi/tui" +import { callTui } from "../../src/server/routes/instance/tui" +import { Log } from "../../src/util" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket + +function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return InstanceRoutes(websocket) +} + +async function expectTrue(path: string, headers: Record, body?: unknown) { + const response = await app().request(path, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + }) + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await Instance.disposeAll() + await resetDatabase() +}) + +describe("tui HttpApi bridge", () => { + test("serves TUI command and event routes through experimental Effect routes", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + + await expectTrue(TuiPaths.appendPrompt, headers, { text: "hello" }) + await expectTrue(TuiPaths.openHelp, headers) + await expectTrue(TuiPaths.openSessions, headers) + await expectTrue(TuiPaths.openThemes, headers) + await expectTrue(TuiPaths.openModels, headers) + await expectTrue(TuiPaths.submitPrompt, headers) + await expectTrue(TuiPaths.clearPrompt, headers) + await expectTrue(TuiPaths.executeCommand, headers, { command: "agent_cycle" }) + await expectTrue(TuiPaths.showToast, headers, { message: "Saved", variant: "success" }) + await expectTrue(TuiPaths.publish, headers, { + type: "tui.prompt.append", + properties: { text: "from publish" }, + }) + + const missing = await app().request(TuiPaths.selectSession, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ sessionID: SessionID.descending() }), + }) + expect(missing.status).toBe(404) + }) + + test("serves TUI control queue through experimental Effect routes", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context) + const headers = { "x-opencode-directory": tmp.path } + + const next = await app().request(TuiPaths.controlNext, { headers }) + expect(next.status).toBe(200) + expect(await next.json()).toEqual({ path: "/demo", body: { value: 1 } }) + + await expectTrue(TuiPaths.controlResponse, headers, { ok: true }) + expect(await pending).toEqual({ ok: true }) + }) +})