From 3e31ee09732cef13ede8e65b282064e9fe17bae8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 16 Oct 2025 15:32:27 -0400 Subject: [PATCH] tui: add event system for TUI command execution and improve SDK integration --- packages/opencode/package.json | 1 + packages/opencode/src/cli/cmd/tui/app.tsx | 36 ++- .../cli/cmd/tui/component/dialog-command.tsx | 3 +- .../cmd/tui/component/dialog-session-list.tsx | 2 +- .../src/cli/cmd/tui/component/dialog-tag.tsx | 2 +- .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 44 +++- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 26 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 248 +++++++++--------- packages/opencode/src/cli/cmd/tui/event.ts | 38 +++ .../cmd/tui/routes/session/dialog-message.tsx | 4 +- .../src/cli/cmd/tui/routes/session/index.tsx | 84 +++++- packages/opencode/src/server/server.ts | 146 ++++++++--- 13 files changed, 439 insertions(+), 197 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/event.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0fb3542ad..4ea447251 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -48,6 +48,7 @@ "@opentui/core": "0.0.0-20251010-2eed09fd", "@opentui/solid": "0.0.0-20251010-2eed09fd", "@parcel/watcher": "2.5.1", + "@solid-primitives/event-bus": "1.1.2", "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 963d05445..3a6c2e3a1 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -6,7 +6,7 @@ import { Switch, Match, createEffect, untrack } from "solid-js" import { Installation } from "@/installation" import { Global } from "@/global" import { DialogProvider, useDialog } from "@tui/ui/dialog" -import { SDKProvider } from "@tui/context/sdk" +import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel } from "@tui/component/dialog-model" @@ -64,17 +64,9 @@ function App() { const dialog = useDialog() const local = useLocal() const command = useCommandDialog() - const keybind = useKeybind() + const { event } = useSDK() useKeyboard(async (evt) => { - if (keybind.match("agent_cycle", evt)) { - local.agent.move(1) - return - } - if (keybind.match("agent_cycle_reverse", evt)) { - local.agent.move(-1) - } - if (evt.meta && evt.name === "t") { renderer.toggleDebugOverlay() return @@ -130,6 +122,26 @@ function App() { dialog.replace(() => ) }, }, + { + title: "Agent cycle", + value: "agent.cycle", + keybind: "agent_cycle", + category: "Agent", + disabled: true, + onSelect: () => { + local.agent.move(1) + }, + }, + { + title: "Agent cycle reverse", + value: "agent.cycle.reverse", + keybind: "agent_cycle_reverse", + category: "Agent", + disabled: true, + onSelect: () => { + local.agent.move(-1) + }, + }, { title: "View status", keybind: "status_view", @@ -154,6 +166,10 @@ function App() { } }) + event.on("tui.command.execute", (evt) => { + command.trigger(evt.properties.command) + }) + return ( { if (toDelete() === option.value) { - sdk.session.delete({ + sdk.client.session.delete({ path: { id: option.value, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx index 652c6a841..78eeded24 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx @@ -15,7 +15,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) { const [files] = createResource( () => [store.filter], async () => { - const result = await sdk.find.files({ + const result = await sdk.client.find.files({ query: { query: store.filter, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 0573e17a8..45d19657e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -53,7 +53,7 @@ export function Autocomplete(props: { if (store.visible === "/") return [] // Get files from SDK - const result = await sdk.find.files({ + const result = await sdk.client.find.files({ query: { query: filter() ?? "", }, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8f643982f..2efbc83d0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -73,9 +73,43 @@ export function Prompt(props: PromptProps) { } }, }, + { + title: "Clear prompt", + value: "prompt.clear", + disabled: true, + keybind: "input_clear", + category: "Prompt", + onSelect: (dialog) => { + setStore("prompt", { + input: "", + parts: [], + }) + dialog.clear() + }, + }, + { + title: "Submit prompt", + value: "prompt.submit", + disabled: true, + keybind: "input_submit", + category: "Prompt", + onSelect: (dialog) => { + submit() + dialog.clear() + }, + }, ] }) + sdk.event.on("tui.prompt.append", (evt) => { + setStore( + "prompt", + produce((draft) => { + draft.input += evt.properties.text + }), + ) + }) + createEffect(() => { if (props.disabled) input.cursorColor = Theme.backgroundElement if (!props.disabled) input.cursorColor = Theme.primary @@ -125,13 +159,13 @@ export function Prompt(props: PromptProps) { const sessionID = props.sessionID ? props.sessionID : await (async () => { - const sessionID = await sdk.session.create({}).then((x) => x.data!.id) + const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id) return sessionID })() const messageID = Identifier.ascending("message") const input = store.prompt.input if (store.mode === "shell") { - sdk.session.shell({ + sdk.client.session.shell({ path: { id: sessionID, }, @@ -143,7 +177,7 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") } else if (input.startsWith("/")) { const [command, ...args] = input.split(" ") - sdk.session.command({ + sdk.client.session.command({ path: { id: sessionID, }, @@ -160,7 +194,7 @@ export function Prompt(props: PromptProps) { parts: [], }) } else { - sdk.session.prompt({ + sdk.client.session.prompt({ path: { id: sessionID, }, @@ -296,7 +330,7 @@ export function Prompt(props: PromptProps) { return } if (e.name === "escape" && props.sessionID) { - sdk.session.abort({ + sdk.client.session.abort({ path: { id: props.sessionID, }, diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 85002cd6a..655c68022 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,17 +1,37 @@ -import { createOpencodeClient } from "@opencode-ai/sdk" +import { createOpencodeClient, type Event } from "@opencode-ai/sdk" import { createSimpleContext } from "./helper" +import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { onCleanup } from "solid-js" export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { url: string }) => { - const client = createOpencodeClient({ + const abort = new AbortController() + const sdk = createOpencodeClient({ baseUrl: props.url, + signal: abort.signal, fetch: (req) => { // @ts-ignore req.timeout = false return fetch(req) }, }) - return client + + const emitter = createGlobalEmitter<{ + [key in Event["type"]]: Extract + }>() + + sdk.event.subscribe().then(async (events) => { + for await (const event of events.stream) { + console.log("event", event.type) + emitter.emit(event.type, event) + } + }) + + onCleanup(() => { + abort.abort() + }) + + return { client: sdk, event: emitter } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e33260010..5cf3358ac 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -59,164 +59,162 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() - sdk.event.subscribe().then(async (events) => { - for await (const event of events.stream) { - console.log(event.type) - switch (event.type) { - case "permission.updated": { - const permissions = store.permission[event.properties.sessionID] - if (!permissions) { - setStore("permission", event.properties.sessionID, [event.properties]) - break - } - const match = Binary.search(permissions, event.properties.id, (p) => p.id) - setStore( - "permission", - event.properties.sessionID, - produce((draft) => { - if (match.found) { - draft[match.index] = event.properties - return - } - draft.push(event.properties) - }), - ) + sdk.event.listen((e) => { + const event = e.details + switch (event.type) { + case "permission.updated": { + const permissions = store.permission[event.properties.sessionID] + if (!permissions) { + setStore("permission", event.properties.sessionID, [event.properties]) break } + const match = Binary.search(permissions, event.properties.id, (p) => p.id) + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + if (match.found) { + draft[match.index] = event.properties + return + } + draft.push(event.properties) + }), + ) + break + } - case "permission.replied": { - const permissions = store.permission[event.properties.sessionID] - const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id) - if (!match.found) break - setStore( - "permission", - event.properties.sessionID, - produce((draft) => { - draft.splice(match.index, 1) - }), - ) - break - } + case "permission.replied": { + const permissions = store.permission[event.properties.sessionID] + const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id) + if (!match.found) break + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + break + } - case "todo.updated": - setStore("todo", event.properties.sessionID, event.properties.todos) - break + case "todo.updated": + setStore("todo", event.properties.sessionID, event.properties.todos) + break - case "session.deleted": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) - if (result.found) { - setStore( - "session", - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - break - } - case "session.updated": - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) - if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) - break - } + case "session.deleted": { + const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) + if (result.found) { setStore( "session", produce((draft) => { - draft.splice(result.index, 0, event.properties.info) + draft.splice(result.index, 1) }), ) + } + break + } + case "session.updated": + const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) + if (result.found) { + setStore("session", result.index, reconcile(event.properties.info)) break - case "message.updated": { - const messages = store.message[event.properties.info.sessionID] - if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) - break - } - const result = Binary.search(messages, event.properties.info.id, (m) => m.id) - if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) - break - } + } + setStore( + "session", + produce((draft) => { + draft.splice(result.index, 0, event.properties.info) + }), + ) + break + case "message.updated": { + const messages = store.message[event.properties.info.sessionID] + if (!messages) { + setStore("message", event.properties.info.sessionID, [event.properties.info]) + break + } + const result = Binary.search(messages, event.properties.info.id, (m) => m.id) + if (result.found) { + setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + break + } + setStore( + "message", + event.properties.info.sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties.info) + }), + ) + break + } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { setStore( "message", - event.properties.info.sessionID, + event.properties.sessionID, produce((draft) => { - draft.splice(result.index, 0, event.properties.info) + draft.splice(result.index, 1) }), ) + } + break + } + case "message.part.updated": { + const parts = store.part[event.properties.part.messageID] + if (!parts) { + setStore("part", event.properties.part.messageID, [event.properties.part]) break } - case "message.removed": { - const messages = store.message[event.properties.sessionID] - const result = Binary.search(messages, event.properties.messageID, (m) => m.id) - if (result.found) { - setStore( - "message", - event.properties.sessionID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } + const result = Binary.search(parts, event.properties.part.id, (p) => p.id) + if (result.found) { + setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) break } - case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] - if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) - break - } - const result = Binary.search(parts, event.properties.part.id, (p) => p.id) - if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) - break - } + setStore( + "part", + event.properties.part.messageID, + produce((draft) => { + draft.splice(result.index, 0, event.properties.part) + }), + ) + break + } + + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) setStore( "part", - event.properties.part.messageID, + event.properties.messageID, produce((draft) => { - draft.splice(result.index, 0, event.properties.part) + draft.splice(result.index, 1) }), ) - break - } + break + } - case "message.part.removed": { - const parts = store.part[event.properties.messageID] - const result = Binary.search(parts, event.properties.partID, (p) => p.id) - if (result.found) - setStore( - "part", - event.properties.messageID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - break - } - - case "lsp.updated": { - sdk.lsp.status().then((x) => setStore("lsp", x.data!)) - break - } + case "lsp.updated": { + sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)) + break } } }) // blocking Promise.all([ - sdk.config.providers().then((x) => setStore("provider", x.data!.providers)), - sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), - sdk.config.get().then((x) => setStore("config", x.data!)), + sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)), + sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])), + sdk.client.config.get().then((x) => setStore("config", x.data!)), ]).then(() => setStore("ready", true)) // non-blocking Promise.all([ - sdk.session.list().then((x) => setStore("session", x.data ?? [])), - sdk.command.list().then((x) => setStore("command", x.data ?? [])), - sdk.lsp.status().then((x) => setStore("lsp", x.data!)), - sdk.mcp.status().then((x) => setStore("mcp", x.data!)), + sdk.client.session.list().then((x) => setStore("session", x.data ?? [])), + sdk.client.command.list().then((x) => setStore("command", x.data ?? [])), + sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)), + sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), ]) const result = { @@ -243,9 +241,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, async sync(sessionID: string) { const [session, messages, todo] = await Promise.all([ - sdk.session.get({ path: { id: sessionID } }), - sdk.session.messages({ path: { id: sessionID } }), - sdk.session.todo({ path: { id: sessionID } }), + sdk.client.session.get({ path: { id: sessionID } }), + sdk.client.session.messages({ path: { id: sessionID } }), + sdk.client.session.todo({ path: { id: sessionID } }), ]) setStore( produce((draft) => { diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts new file mode 100644 index 000000000..49ef2e857 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -0,0 +1,38 @@ +import { Bus } from "@/bus" +import z from "zod" + +export const TuiEvent = { + PromptAppend: Bus.event("tui.prompt.append", z.object({ text: z.string() })), + CommandExecute: Bus.event( + "tui.command.execute", + z.object({ + command: z.union([ + z.enum([ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle", + ]), + z.string(), + ]), + }), + ), + ToastShow: Bus.event( + "tui.toast.show", + z.object({ + title: z.string().optional(), + message: z.string(), + variant: z.enum(["info", "success", "warning", "error"]), + }), + ), +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index b2cb3a9a9..ee2b77afc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -19,7 +19,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) { value: "session.revert", description: "undo messages and file changes", onSelect: (dialog) => { - sdk.session.revert({ + sdk.client.session.revert({ path: { id: props.sessionID, }, @@ -35,7 +35,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) { value: "session.fork", description: "create a new session", onSelect: async (dialog) => { - const result = await sdk.session.fork({ + const result = await sdk.client.session.fork({ path: { id: props.sessionID, }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 0adb854e1..88c326d39 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -82,10 +82,6 @@ export function Session() { useKeyboard((evt) => { if (dialog.stack.length > 0) return - if (keybind.match("messages_page_up", evt)) scroll.scrollBy(-scroll.height / 2) - if (keybind.match("messages_page_down", evt)) scroll.scrollBy(scroll.height / 2) - if (keybind.match("messages_first", evt)) scroll.scrollTo(0) - if (keybind.match("messages_last", evt)) scroll.scrollTo(scroll.scrollHeight) const first = permissions()[0] if (first) { @@ -96,7 +92,7 @@ export function Session() { return }) if (response) { - sdk.postSessionIdPermissionsPermissionId({ + sdk.client.postSessionIdPermissionsPermissionId({ path: { permissionID: first.id, id: route.sessionID, @@ -150,7 +146,7 @@ export function Session() { keybind: "session_compact", category: "Session", onSelect: (dialog) => { - sdk.session.summarize({ + sdk.client.session.summarize({ path: { id: route.sessionID, }, @@ -169,7 +165,7 @@ export function Session() { disabled: !!session()?.share?.url, category: "Session", onSelect: (dialog) => { - sdk.session.share({ + sdk.client.session.share({ path: { id: route.sessionID, }, @@ -184,7 +180,7 @@ export function Session() { disabled: !session()?.share?.url, category: "Session", onSelect: (dialog) => { - sdk.session.unshare({ + sdk.client.session.unshare({ path: { id: route.sessionID, }, @@ -201,7 +197,7 @@ export function Session() { const revert = session().revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return - sdk.session.revert({ + sdk.client.session.revert({ path: { id: route.sessionID, }, @@ -235,7 +231,7 @@ export function Session() { if (!messageID) return const message = messages().find((x) => x.role === "user" && x.id > messageID) if (!message) { - sdk.session.unrevert({ + sdk.client.session.unrevert({ path: { id: route.sessionID, }, @@ -243,7 +239,7 @@ export function Session() { prompt.set({ input: "", parts: [] }) return } - sdk.session.revert({ + sdk.client.session.revert({ path: { id: route.sessionID, }, @@ -267,6 +263,72 @@ export function Session() { dialog.clear() }, }, + { + title: "Page up", + value: "session.page.up", + keybind: "messages_page_up", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(-scroll.height / 2) + dialog.clear() + }, + }, + { + title: "Page down", + value: "session.page.down", + keybind: "messages_page_down", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(scroll.height / 2) + dialog.clear() + }, + }, + { + title: "Half page up", + value: "session.half.page.up", + keybind: "messages_half_page_up", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(-scroll.height / 4) + dialog.clear() + }, + }, + { + title: "Half page down", + value: "session.half.page.down", + keybind: "messages_half_page_down", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(scroll.height / 4) + dialog.clear() + }, + }, + { + title: "First message", + value: "session.first", + keybind: "messages_first", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollTo(0) + dialog.clear() + }, + }, + { + title: "Last message", + value: "session.last", + keybind: "messages_last", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollTo(scroll.scrollHeight) + dialog.clear() + }, + }, ]) const revert = createMemo(() => { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 5d0c8aafe..537bddefc 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -15,7 +15,7 @@ import { Config } from "../config/config" import { File } from "../file" import { LSP } from "../lsp" import { MessageV2 } from "../session/message-v2" -import { callTui, TuiRoute } from "./tui" +import { TuiRoute } from "./tui" import { Permission } from "../permission" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" @@ -35,6 +35,7 @@ import { InstanceBootstrap } from "../project/bootstrap" import { MCP } from "../mcp" import { Storage } from "../storage/storage" import type { ContentfulStatusCode } from "hono/utils/http-status" +import { TuiEvent } from "@/cli/cmd/tui/event" const ERRORS = { 400: { @@ -59,9 +60,7 @@ const ERRORS = { description: "Not found", content: { "application/json": { - schema: resolver( - Storage.NotFoundError.Schema - ) + schema: resolver(Storage.NotFoundError.Schema), }, }, }, @@ -87,12 +86,9 @@ export namespace Server { }) if (err instanceof NamedError) { let status: ContentfulStatusCode - if (err instanceof Storage.NotFoundError) - status = 404 - else if (err instanceof Provider.ModelNotFoundError) - status = 400 - else - status = 500 + if (err instanceof Storage.NotFoundError) status = 404 + else if (err instanceof Provider.ModelNotFoundError) status = 400 + else status = 500 return c.json(err.toObject(), { status }) } const message = err instanceof Error && err.stack ? err.stack : err.toString() @@ -449,7 +445,9 @@ export namespace Server { }), ), async (c) => { - await Session.remove(c.req.valid("param").id) + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) return c.json(true) }, ) @@ -1288,13 +1286,11 @@ export namespace Server { ...errors(400), }, }), - validator( - "json", - z.object({ - text: z.string(), - }), - ), - async (c) => c.json(await callTui(c)), + validator("json", TuiEvent.PromptAppend.properties), + async (c) => { + await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json")) + return c.json(true) + }, ) .post( "/tui/open-help", @@ -1312,7 +1308,10 @@ export namespace Server { }, }, }), - async (c) => c.json(await callTui(c)), + async (c) => { + // TODO: open dialog + return c.json(true) + }, ) .post( "/tui/open-sessions", @@ -1330,7 +1329,12 @@ export namespace Server { }, }, }), - async (c) => c.json(await callTui(c)), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) + return c.json(true) + }, ) .post( "/tui/open-themes", @@ -1348,7 +1352,12 @@ export namespace Server { }, }, }), - async (c) => c.json(await callTui(c)), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) + return c.json(true) + }, ) .post( "/tui/open-models", @@ -1366,7 +1375,12 @@ export namespace Server { }, }, }), - async (c) => c.json(await callTui(c)), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "model.list", + }) + return c.json(true) + }, ) .post( "/tui/submit-prompt", @@ -1384,7 +1398,12 @@ export namespace Server { }, }, }), - async (c) => c.json(await callTui(c)), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.submit", + }) + return c.json(true) + }, ) .post( "/tui/clear-prompt", @@ -1402,7 +1421,12 @@ export namespace Server { }, }, }), - async (c) => c.json(await callTui(c)), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.clear", + }) + return c.json(true) + }, ) .post( "/tui/execute-command", @@ -1421,13 +1445,27 @@ export namespace Server { ...errors(400), }, }), - validator( - "json", - z.object({ - command: z.string(), - }), - ), - async (c) => c.json(await callTui(c)), + validator("json", z.object({ command: z.string() })), + async (c) => { + const command = c.req.valid("json").command + await Bus.publish(TuiEvent.CommandExecute, { + // @ts-expect-error + command: { + 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_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", + }[command], + }) + return c.json(true) + }, ) .post( "/tui/show-toast", @@ -1445,15 +1483,49 @@ export namespace Server { }, }, }), + validator("json", TuiEvent.ToastShow.properties), + async (c) => { + await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) + return c.json(true) + }, + ) + .post( + "/tui/publish", + describeRoute({ + description: "Publish a TUI event", + operationId: "tui.publish", + responses: { + 200: { + description: "Event published successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), validator( "json", - z.object({ - title: z.string().optional(), - message: z.string(), - variant: z.enum(["info", "success", "warning", "error"]), - }), + z.union( + Object.values(TuiEvent).map((def) => { + return z + .object({ + type: z.literal(def.type), + properties: def.properties, + }) + .meta({ + ref: "Event" + "." + def.type, + }) + }), + ), ), - async (c) => c.json(await callTui(c)), + async (c) => { + const evt = c.req.valid("json") + await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties) + return c.json(true) + }, ) .route("/tui/control", TuiRoute) .put(