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(