feat(httpapi): bridge tui routes (#24548)
This commit is contained in:
@@ -329,19 +329,19 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
|||||||
|
|
||||||
### TUI Routes
|
### TUI Routes
|
||||||
|
|
||||||
- [ ] `POST /tui/append-prompt` - append prompt.
|
- [x] `POST /tui/append-prompt` - append prompt.
|
||||||
- [ ] `POST /tui/open-help` - open help.
|
- [x] `POST /tui/open-help` - open help.
|
||||||
- [ ] `POST /tui/open-sessions` - open sessions.
|
- [x] `POST /tui/open-sessions` - open sessions.
|
||||||
- [ ] `POST /tui/open-themes` - open themes.
|
- [x] `POST /tui/open-themes` - open themes.
|
||||||
- [ ] `POST /tui/open-models` - open models.
|
- [x] `POST /tui/open-models` - open models.
|
||||||
- [ ] `POST /tui/submit-prompt` - submit prompt.
|
- [x] `POST /tui/submit-prompt` - submit prompt.
|
||||||
- [ ] `POST /tui/clear-prompt` - clear prompt.
|
- [x] `POST /tui/clear-prompt` - clear prompt.
|
||||||
- [ ] `POST /tui/execute-command` - execute command.
|
- [x] `POST /tui/execute-command` - execute command.
|
||||||
- [ ] `POST /tui/show-toast` - show toast.
|
- [x] `POST /tui/show-toast` - show toast.
|
||||||
- [ ] `POST /tui/publish` - publish TUI event.
|
- [x] `POST /tui/publish` - publish TUI event.
|
||||||
- [ ] `POST /tui/select-session` - select session.
|
- [x] `POST /tui/select-session` - select session.
|
||||||
- [ ] `GET /tui/control/next` - get next TUI request.
|
- [x] `GET /tui/control/next` - get next TUI request.
|
||||||
- [ ] `POST /tui/control/response` - submit TUI control response.
|
- [x] `POST /tui/control/response` - submit TUI control response.
|
||||||
|
|
||||||
## Remaining PR Plan
|
## 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.
|
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
||||||
10. [x] Bridge remaining session mutation and prompt routes.
|
10. [x] Bridge remaining session mutation and prompt routes.
|
||||||
11. [ ] Replace event SSE with non-Hono Effect HTTP.
|
11. [ ] Replace event SSE with non-Hono Effect HTTP.
|
||||||
12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
|
12. [x] 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.
|
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.
|
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.
|
15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||||
import { Observability } from "@/effect"
|
import { Observability } from "@/effect"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
|
import { Session } from "@/session"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { authorizationLayer } from "./auth"
|
import { authorizationLayer } from "./auth"
|
||||||
@@ -23,6 +25,7 @@ import { ProviderApi, providerHandlers } from "./provider"
|
|||||||
import { QuestionApi, questionHandlers } from "./question"
|
import { QuestionApi, questionHandlers } from "./question"
|
||||||
import { SessionApi, sessionHandlers } from "./session"
|
import { SessionApi, sessionHandlers } from "./session"
|
||||||
import { SyncApi, syncHandlers } from "./sync"
|
import { SyncApi, syncHandlers } from "./sync"
|
||||||
|
import { TuiApi, tuiHandlers } from "./tui"
|
||||||
import { WorkspaceApi, workspaceHandlers } from "./workspace"
|
import { WorkspaceApi, workspaceHandlers } from "./workspace"
|
||||||
import { disposeMiddleware } from "./lifecycle"
|
import { disposeMiddleware } from "./lifecycle"
|
||||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
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(ProviderApi).pipe(Layer.provide(providerHandlers)),
|
||||||
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
|
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
|
||||||
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
|
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)),
|
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
|
||||||
).pipe(
|
).pipe(
|
||||||
Layer.provide(authorizationLayer),
|
Layer.provide(authorizationLayer),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -24,6 +24,7 @@ import { InstancePaths } from "./httpapi/instance"
|
|||||||
import { McpPaths } from "./httpapi/mcp"
|
import { McpPaths } from "./httpapi/mcp"
|
||||||
import { SessionPaths } from "./httpapi/session"
|
import { SessionPaths } from "./httpapi/session"
|
||||||
import { SyncPaths } from "./httpapi/sync"
|
import { SyncPaths } from "./httpapi/sync"
|
||||||
|
import { TuiPaths } from "./httpapi/tui"
|
||||||
import { ProjectRoutes } from "./project"
|
import { ProjectRoutes } from "./project"
|
||||||
import { SessionRoutes } from "./session"
|
import { SessionRoutes } from "./session"
|
||||||
import { PtyRoutes } from "./pty"
|
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.deleteMessage, (c) => handler(c.req.raw, context))
|
||||||
app.delete(SessionPaths.deletePart, (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.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
|
return app
|
||||||
|
|||||||
@@ -12,15 +12,23 @@ import { errors } from "../../error"
|
|||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { runRequest } from "./trace"
|
import { runRequest } from "./trace"
|
||||||
|
|
||||||
const TuiRequest = z.object({
|
export const TuiRequest = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
body: z.any(),
|
body: z.any(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type TuiRequest = z.infer<typeof TuiRequest>
|
export type TuiRequest = z.infer<typeof TuiRequest>
|
||||||
|
|
||||||
const request = new AsyncQueue<TuiRequest>()
|
const request = new AsyncQueue<TuiRequest>()
|
||||||
const response = new AsyncQueue<any>()
|
const response = new AsyncQueue<unknown>()
|
||||||
|
|
||||||
|
export function nextTuiRequest() {
|
||||||
|
return request.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitTuiResponse(body: unknown) {
|
||||||
|
response.push(body)
|
||||||
|
}
|
||||||
|
|
||||||
export async function callTui(ctx: Context) {
|
export async function callTui(ctx: Context) {
|
||||||
const body = await ctx.req.json()
|
const body = await ctx.req.json()
|
||||||
@@ -50,7 +58,7 @@ const TuiControlRoutes = new Hono()
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const req = await request.next()
|
const req = await nextTuiRequest()
|
||||||
return c.json(req)
|
return c.json(req)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -74,7 +82,7 @@ const TuiControlRoutes = new Hono()
|
|||||||
validator("json", z.any()),
|
validator("json", z.any()),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
response.push(body)
|
submitTuiResponse(body)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<string, string>, 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user