Skip to content

Commit 418a1cf

Browse files
authored
feat(httpapi): bridge tui routes (#24548)
1 parent 60ebd07 commit 418a1cf

6 files changed

Lines changed: 415 additions & 20 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -329,19 +329,19 @@ This checklist tracks bridge parity only. Checked routes are available through t
329329

330330
### TUI Routes
331331

332-
- [ ] `POST /tui/append-prompt` - append prompt.
333-
- [ ] `POST /tui/open-help` - open help.
334-
- [ ] `POST /tui/open-sessions` - open sessions.
335-
- [ ] `POST /tui/open-themes` - open themes.
336-
- [ ] `POST /tui/open-models` - open models.
337-
- [ ] `POST /tui/submit-prompt` - submit prompt.
338-
- [ ] `POST /tui/clear-prompt` - clear prompt.
339-
- [ ] `POST /tui/execute-command` - execute command.
340-
- [ ] `POST /tui/show-toast` - show toast.
341-
- [ ] `POST /tui/publish` - publish TUI event.
342-
- [ ] `POST /tui/select-session` - select session.
343-
- [ ] `GET /tui/control/next` - get next TUI request.
344-
- [ ] `POST /tui/control/response` - submit TUI control response.
332+
- [x] `POST /tui/append-prompt` - append prompt.
333+
- [x] `POST /tui/open-help` - open help.
334+
- [x] `POST /tui/open-sessions` - open sessions.
335+
- [x] `POST /tui/open-themes` - open themes.
336+
- [x] `POST /tui/open-models` - open models.
337+
- [x] `POST /tui/submit-prompt` - submit prompt.
338+
- [x] `POST /tui/clear-prompt` - clear prompt.
339+
- [x] `POST /tui/execute-command` - execute command.
340+
- [x] `POST /tui/show-toast` - show toast.
341+
- [x] `POST /tui/publish` - publish TUI event.
342+
- [x] `POST /tui/select-session` - select session.
343+
- [x] `GET /tui/control/next` - get next TUI request.
344+
- [x] `POST /tui/control/response` - submit TUI control response.
345345

346346
## Remaining PR Plan
347347

@@ -358,8 +358,8 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
358358
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
359359
10. [x] Bridge remaining session mutation and prompt routes.
360360
11. [ ] Replace event SSE with non-Hono Effect HTTP.
361-
12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
362-
13. [ ] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
361+
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP.
362+
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
363363
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
364364
15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
365365

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Effect, Layer, Schema } from "effect"
22
import { HttpApiBuilder } from "effect/unstable/httpapi"
33
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
4+
import { Bus } from "@/bus"
45
import { AppRuntime } from "@/effect/app-runtime"
56
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
67
import { Observability } from "@/effect"
78
import { InstanceBootstrap } from "@/project/bootstrap"
89
import { Instance } from "@/project/instance"
910
import { Pty } from "@/pty"
11+
import { Session } from "@/session"
1012
import { lazy } from "@/util/lazy"
1113
import { Filesystem } from "@/util"
1214
import { authorizationLayer } from "./auth"
@@ -23,6 +25,7 @@ import { ProviderApi, providerHandlers } from "./provider"
2325
import { QuestionApi, questionHandlers } from "./question"
2426
import { SessionApi, sessionHandlers } from "./session"
2527
import { SyncApi, syncHandlers } from "./sync"
28+
import { TuiApi, tuiHandlers } from "./tui"
2629
import { WorkspaceApi, workspaceHandlers } from "./workspace"
2730
import { disposeMiddleware } from "./lifecycle"
2831
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -83,6 +86,11 @@ export const routes = Layer.mergeAll(
8386
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
8487
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
8588
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
89+
HttpApiBuilder.layer(TuiApi).pipe(
90+
Layer.provide(tuiHandlers),
91+
Layer.provide(Session.defaultLayer),
92+
Layer.provide(Bus.layer),
93+
),
8694
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
8795
).pipe(
8896
Layer.provide(authorizationLayer),
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { Bus } from "@/bus"
2+
import { TuiEvent } from "@/cli/cmd/tui/event"
3+
import { Session } from "@/session"
4+
import { SessionID } from "@/session/schema"
5+
import { Effect, Layer, Schema } from "effect"
6+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
7+
import { nextTuiRequest, submitTuiResponse } from "../tui"
8+
import { Authorization } from "./auth"
9+
10+
const root = "/tui"
11+
const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" })
12+
const TuiRequestPayload = Schema.Struct({
13+
path: Schema.String,
14+
body: Schema.Unknown,
15+
}).annotate({ identifier: "TuiRequest" })
16+
const TuiPublishPayload = Schema.Union([
17+
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }),
18+
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }),
19+
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }),
20+
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }),
21+
]).annotate({ identifier: "TuiEventInput" })
22+
23+
const commandAliases = {
24+
session_new: "session.new",
25+
session_share: "session.share",
26+
session_interrupt: "session.interrupt",
27+
session_compact: "session.compact",
28+
messages_page_up: "session.page.up",
29+
messages_page_down: "session.page.down",
30+
messages_line_up: "session.line.up",
31+
messages_line_down: "session.line.down",
32+
messages_half_page_up: "session.half.page.up",
33+
messages_half_page_down: "session.half.page.down",
34+
messages_first: "session.first",
35+
messages_last: "session.last",
36+
agent_cycle: "agent.cycle",
37+
} as const
38+
39+
export const TuiPaths = {
40+
appendPrompt: `${root}/append-prompt`,
41+
openHelp: `${root}/open-help`,
42+
openSessions: `${root}/open-sessions`,
43+
openThemes: `${root}/open-themes`,
44+
openModels: `${root}/open-models`,
45+
submitPrompt: `${root}/submit-prompt`,
46+
clearPrompt: `${root}/clear-prompt`,
47+
executeCommand: `${root}/execute-command`,
48+
showToast: `${root}/show-toast`,
49+
publish: `${root}/publish`,
50+
selectSession: `${root}/select-session`,
51+
controlNext: `${root}/control/next`,
52+
controlResponse: `${root}/control/response`,
53+
} as const
54+
55+
export const TuiApi = HttpApi.make("tui")
56+
.add(
57+
HttpApiGroup.make("tui")
58+
.add(
59+
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
60+
payload: TuiEvent.PromptAppend.properties,
61+
success: Schema.Boolean,
62+
}).annotateMerge(
63+
OpenApi.annotations({
64+
identifier: "tui.appendPrompt",
65+
summary: "Append TUI prompt",
66+
description: "Append prompt to the TUI.",
67+
}),
68+
),
69+
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge(
70+
OpenApi.annotations({
71+
identifier: "tui.openHelp",
72+
summary: "Open help dialog",
73+
description: "Open the help dialog in the TUI to display user assistance information.",
74+
}),
75+
),
76+
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge(
77+
OpenApi.annotations({
78+
identifier: "tui.openSessions",
79+
summary: "Open sessions dialog",
80+
description: "Open the session dialog.",
81+
}),
82+
),
83+
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge(
84+
OpenApi.annotations({
85+
identifier: "tui.openThemes",
86+
summary: "Open themes dialog",
87+
description: "Open the theme dialog.",
88+
}),
89+
),
90+
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge(
91+
OpenApi.annotations({
92+
identifier: "tui.openModels",
93+
summary: "Open models dialog",
94+
description: "Open the model dialog.",
95+
}),
96+
),
97+
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge(
98+
OpenApi.annotations({
99+
identifier: "tui.submitPrompt",
100+
summary: "Submit TUI prompt",
101+
description: "Submit the prompt.",
102+
}),
103+
),
104+
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge(
105+
OpenApi.annotations({
106+
identifier: "tui.clearPrompt",
107+
summary: "Clear TUI prompt",
108+
description: "Clear the prompt.",
109+
}),
110+
),
111+
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
112+
payload: CommandPayload,
113+
success: Schema.Boolean,
114+
}).annotateMerge(
115+
OpenApi.annotations({
116+
identifier: "tui.executeCommand",
117+
summary: "Execute TUI command",
118+
description: "Execute a TUI command.",
119+
}),
120+
),
121+
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
122+
payload: TuiEvent.ToastShow.properties,
123+
success: Schema.Boolean,
124+
}).annotateMerge(
125+
OpenApi.annotations({
126+
identifier: "tui.showToast",
127+
summary: "Show TUI toast",
128+
description: "Show a toast notification in the TUI.",
129+
}),
130+
),
131+
HttpApiEndpoint.post("publish", TuiPaths.publish, {
132+
payload: TuiPublishPayload,
133+
success: Schema.Boolean,
134+
}).annotateMerge(
135+
OpenApi.annotations({
136+
identifier: "tui.publish",
137+
summary: "Publish TUI event",
138+
description: "Publish a TUI event.",
139+
}),
140+
),
141+
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
142+
payload: TuiEvent.SessionSelect.properties,
143+
success: Schema.Boolean,
144+
error: HttpApiError.NotFound,
145+
}).annotateMerge(
146+
OpenApi.annotations({
147+
identifier: "tui.selectSession",
148+
summary: "Select session",
149+
description: "Navigate the TUI to display the specified session.",
150+
}),
151+
),
152+
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge(
153+
OpenApi.annotations({
154+
identifier: "tui.control.next",
155+
summary: "Get next TUI request",
156+
description: "Retrieve the next TUI request from the queue for processing.",
157+
}),
158+
),
159+
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
160+
payload: Schema.Unknown,
161+
success: Schema.Boolean,
162+
}).annotateMerge(
163+
OpenApi.annotations({
164+
identifier: "tui.control.response",
165+
summary: "Submit TUI response",
166+
description: "Submit a response to the TUI request queue to complete a pending request.",
167+
}),
168+
),
169+
)
170+
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
171+
.middleware(Authorization),
172+
)
173+
.annotateMerge(
174+
OpenApi.annotations({
175+
title: "opencode experimental HttpApi",
176+
version: "0.0.1",
177+
description: "Experimental HttpApi surface for selected instance routes.",
178+
}),
179+
)
180+
181+
export const tuiHandlers = Layer.unwrap(
182+
Effect.gen(function* () {
183+
const bus = yield* Bus.Service
184+
const session = yield* Session.Service
185+
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
186+
bus.publish(TuiEvent.CommandExecute, { command })
187+
188+
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
189+
payload: typeof TuiEvent.PromptAppend.properties.Type
190+
}) {
191+
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
192+
return true
193+
})
194+
195+
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
196+
yield* publishCommand("help.show")
197+
return true
198+
})
199+
200+
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
201+
yield* publishCommand("session.list")
202+
return true
203+
})
204+
205+
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
206+
yield* publishCommand("session.list")
207+
return true
208+
})
209+
210+
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
211+
yield* publishCommand("model.list")
212+
return true
213+
})
214+
215+
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
216+
yield* publishCommand("prompt.submit")
217+
return true
218+
})
219+
220+
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
221+
yield* publishCommand("prompt.clear")
222+
return true
223+
})
224+
225+
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
226+
payload: typeof CommandPayload.Type
227+
}) {
228+
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
229+
return true
230+
})
231+
232+
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
233+
payload: typeof TuiEvent.ToastShow.properties.Type
234+
}) {
235+
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
236+
return true
237+
})
238+
239+
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
240+
if (ctx.payload.type === TuiEvent.PromptAppend.type)
241+
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
242+
if (ctx.payload.type === TuiEvent.CommandExecute.type)
243+
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
244+
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
245+
if (ctx.payload.type === TuiEvent.SessionSelect.type)
246+
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
247+
return true
248+
})
249+
250+
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
251+
payload: typeof TuiEvent.SessionSelect.properties.Type
252+
}) {
253+
yield* session
254+
.get(ctx.payload.sessionID)
255+
.pipe(Effect.catchCause(() => Effect.fail(new HttpApiError.NotFound({}))))
256+
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
257+
return true
258+
})
259+
260+
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
261+
return yield* Effect.promise(() => nextTuiRequest())
262+
})
263+
264+
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
265+
submitTuiResponse(ctx.payload)
266+
return true
267+
})
268+
269+
return HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
270+
handlers
271+
.handle("appendPrompt", appendPrompt)
272+
.handle("openHelp", openHelp)
273+
.handle("openSessions", openSessions)
274+
.handle("openThemes", openThemes)
275+
.handle("openModels", openModels)
276+
.handle("submitPrompt", submitPrompt)
277+
.handle("clearPrompt", clearPrompt)
278+
.handle("executeCommand", executeCommand)
279+
.handle("showToast", showToast)
280+
.handle("publish", publish)
281+
.handle("selectSession", selectSession)
282+
.handle("controlNext", controlNext)
283+
.handle("controlResponse", controlResponse),
284+
)
285+
}),
286+
)

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { InstancePaths } from "./httpapi/instance"
2424
import { McpPaths } from "./httpapi/mcp"
2525
import { SessionPaths } from "./httpapi/session"
2626
import { SyncPaths } from "./httpapi/sync"
27+
import { TuiPaths } from "./httpapi/tui"
2728
import { ProjectRoutes } from "./project"
2829
import { SessionRoutes } from "./session"
2930
import { PtyRoutes } from "./pty"
@@ -130,6 +131,19 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
130131
app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
131132
app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
132133
app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
134+
app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context))
135+
app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context))
136+
app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context))
137+
app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context))
138+
app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context))
139+
app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context))
140+
app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context))
141+
app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context))
142+
app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context))
143+
app.post(TuiPaths.publish, (c) => handler(c.req.raw, context))
144+
app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context))
145+
app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context))
146+
app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context))
133147
}
134148

135149
return app

0 commit comments

Comments
 (0)