tui: add event system for TUI command execution and improve SDK integration

This commit is contained in:
Dax Raad 2025-10-16 15:32:27 -04:00
parent e54746878f
commit 3e31ee0973
13 changed files with 439 additions and 197 deletions

View file

@ -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:",

View file

@ -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(() => <DialogAgent />)
},
},
{
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 (
<box
width={dimensions().width}

View file

@ -38,7 +38,7 @@ function init() {
}
})
return {
const result = {
trigger(name: string) {
for (const option of options()) {
if (option.value === name) {
@ -58,6 +58,7 @@ function init() {
return options()
},
}
return result
}
export function useCommandDialog() {

View file

@ -62,7 +62,7 @@ export function DialogSessionList() {
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
sdk.session.delete({
sdk.client.session.delete({
path: {
id: option.value,
},

View file

@ -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,
},

View file

@ -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() ?? "",
},

View file

@ -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,
},

View file

@ -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<Event, { type: key }>
}>()
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 }
},
})

View file

@ -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) => {

View file

@ -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"]),
}),
),
}

View file

@ -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,
},

View file

@ -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(() => {

View file

@ -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(