feat(tui): wire engine data layer

This commit is contained in:
Kit Langton
2026-08-17 17:20:29 -04:00
parent 93c24cafa7
commit f253bdaeef
5 changed files with 106 additions and 15 deletions
+24 -11
View File
@@ -72,9 +72,12 @@ export function createEngineData(config: CreateDataInput) {
const update = (sessionID: string, view: Engine.SessionView) => {
batch(() => {
setViews(sessionID, reconcile(view))
legacy.session.remember(view.session)
view.children.forEach(legacy.session.remember)
setViews(sessionID, reconcile(structuredClone(view)))
const current = legacy.session.get(sessionID)
if (!current || current.time.updated <= view.session.time.updated) {
legacy.session.remember(structuredClone(view.session))
}
view.children.forEach((child) => legacy.session.remember(structuredClone(child)))
})
}
@@ -101,40 +104,50 @@ export function createEngineData(config: CreateDataInput) {
...legacy,
session: {
...legacy.session,
sync(sessionID: string) {
sync(sessionID: string, _options?: { readonly children?: boolean }) {
return ensure(sessionID).then(() => undefined)
},
status(sessionID: string) {
return views[sessionID]?.active ?? legacy.session.status(sessionID)
if (views[sessionID]?.active === "running") return "running"
return legacy.session.status(sessionID)
},
input: {
list(sessionID: string) {
return views[sessionID]?.pending.filter((item) => item.type !== "compaction").map((item) => item.id) ?? []
return (
views[sessionID]?.pending.filter((item) => item.type !== "compaction").map((item) => item.id) ??
legacy.session.input.list(sessionID)
)
},
has(sessionID: string, inboxID: string) {
return views[sessionID]?.pending.some((item) => item.type !== "compaction" && item.id === inboxID) ?? false
return (
views[sessionID]?.pending.some((item) => item.type !== "compaction" && item.id === inboxID) ??
legacy.session.input.has(sessionID, inboxID)
)
},
},
pending: {
list(sessionID: string) {
return views[sessionID]?.pending ?? []
void ensure(sessionID)
return [...(views[sessionID]?.pending ?? [])]
},
sync(sessionID: string) {
return ensure(sessionID).then(() => undefined)
},
invalidate() {},
invalidate(_sessionID: string) {},
},
message: {
list(sessionID: string) {
return views[sessionID]?.messages ?? []
void ensure(sessionID)
return [...(views[sessionID]?.messages ?? [])]
},
get(sessionID: string, messageID: string) {
void ensure(sessionID)
return views[sessionID]?.messages.find((message) => message.id === messageID)
},
sync(sessionID: string) {
return ensure(sessionID).then(() => undefined)
},
invalidate() {},
invalidate(_sessionID: string) {},
},
async prompt(input: SessionPromptInput) {
return (await ensure(input.sessionID)).submit({
@@ -276,6 +276,7 @@ export function render(state: Pick<EngineState, "folded" | "outbox" | "overlay">
[
...state.folded.messages,
...pending.flatMap((item): ReadonlyArray<SessionMessageInfo> => {
if (item.type !== "compaction" && item.delivery === "queue") return []
if (messageIDs.has(item.id)) return []
const message = SessionFold.messageFromInbox(item)
return message ? [message] : []
+6 -1
View File
@@ -208,6 +208,11 @@ export function Prompt(props: PromptProps) {
const config = useConfig().data
const dialog = useDialog()
const toast = useToast()
onCleanup(
data.session.failures.listen((failure) => {
toast.show({ title: "Prompt rejected", message: failure.reason, variant: "error" })
}),
)
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const history = usePromptHistory()
const stash = usePromptStash()
@@ -1304,7 +1309,7 @@ export function Prompt(props: PromptProps) {
return false
}
}
const error = await client.api.session
const error = await data.session
.prompt({
sessionID,
text: inputText,
+2 -2
View File
@@ -1,4 +1,4 @@
import { createData } from "@opencode-ai/client/solid"
import { createEngineData } from "@opencode-ai/client/solid"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -10,7 +10,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: () => {
const client = useClient()
const data = createData({
const data = createEngineData({
api: () => client.api,
event: client.event,
connection: client.connection,
+73 -1
View File
@@ -16,6 +16,9 @@ export function createEventStream() {
const encoder = new TextEncoder()
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
const pending: Uint8Array[] = []
const logs = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>()
const logSeq = new Map<string, number>()
const logHistory = new Map<string, Array<{ readonly seq: number; readonly event: unknown }>>()
const response = (
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
queued: Uint8Array[],
@@ -27,7 +30,8 @@ export function createEventStream() {
start(controller) {
current = controller
controllers.add(controller)
if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`))
const values = Array.isArray(initial) ? initial : initial ? [initial] : []
for (const value of values) controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
for (const chunk of queued.splice(0)) controller.enqueue(chunk)
},
cancel() {
@@ -53,13 +57,44 @@ export function createEventStream() {
return {
emit(event: OpenCodeEvent) {
send(v2, pending, event)
const sessionID =
"durable" in event
? event.durable.aggregateID
: "sessionID" in event.data && typeof event.data.sessionID === "string"
? event.data.sessionID
: undefined
if (!sessionID) return
const seq = (logSeq.get(sessionID) ?? 0) + 1
const item = "durable" in event ? { ...event, durable: { ...event.durable, seq } } : event
if ("durable" in event) {
logSeq.set(sessionID, seq)
logHistory.set(sessionID, [...(logHistory.get(sessionID) ?? []), { seq, event: item }])
}
const controllers = logs.get(sessionID)
if (controllers) send(controllers, [], item)
},
v2() {
return response(v2, pending, { id: "evt_connected", type: "server.connected", data: {} })
},
log(sessionID: string, after: number) {
const controllers = logs.get(sessionID) ?? new Set<ReadableStreamDefaultController<Uint8Array>>()
logs.set(sessionID, controllers)
return response(
controllers,
[],
[
...(logHistory.get(sessionID) ?? []).filter((entry) => entry.seq > after).map((entry) => entry.event),
{ type: "log.synced", aggregateID: sessionID, seq: logSeq.get(sessionID) ?? 0 },
],
)
},
disconnect() {
for (const controller of v2) controller.close()
v2.clear()
for (const controllers of logs.values()) {
for (const controller of controllers) controller.close()
controllers.clear()
}
},
}
}
@@ -68,6 +103,7 @@ export type FetchHandler = (url: URL, request: Request) => Response | undefined
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
const session = [] as URL[]
const sessionEvents = events ?? createEventStream()
async function fetch(input: RequestInfo | URL, init?: RequestInit) {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
@@ -75,6 +111,42 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
const overridden = await override?.(url, request)
if (overridden) return overridden
if (url.pathname === "/api/event" && events) return events.v2()
const snapshot = url.pathname.match(/^\/api\/session\/([^/]+)\/snapshot$/)
if (snapshot) {
const sessionID = decodeURIComponent(snapshot[1])
const read = async (path: string, fallback: unknown) => {
const response = await override?.(new URL(path, url), new Request(new URL(path, url)))
if (!response) return fallback
const body = await response.json()
if (typeof body !== "object" || body === null || !("data" in body)) return fallback
return body.data
}
const children = await read(`/api/session?parentID=${encodeURIComponent(sessionID)}`, [])
const messages = await read(`/api/session/${encodeURIComponent(sessionID)}/message`, [])
return json({
data: {
session: await read(`/api/session/${encodeURIComponent(sessionID)}`, {
id: sessionID,
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}),
children: Array.isArray(children)
? children.filter(
(child) =>
typeof child === "object" && child !== null && "parentID" in child && child.parentID === sessionID,
)
: [],
inbox: await read(`/api/session/${encodeURIComponent(sessionID)}/inbox`, []),
messages: Array.isArray(messages) ? messages.toReversed() : [],
seq: 0,
},
})
}
const log = url.pathname.match(/^\/api\/experimental\/session\/([^/]+)\/log$/)
if (log) return sessionEvents.log(decodeURIComponent(log[1]), Number(url.searchParams.get("after") ?? 0))
if (
[