refactor(tui): wire generated client reads (#34381)

This commit is contained in:
Kit Langton 2026-06-29 11:36:10 -04:00 committed by GitHub
commit 381d67572e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 509 additions and 427 deletions

View file

@ -61,6 +61,14 @@ function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
}
type Mutable<T> =
T extends ReadonlyArray<infer U> ? Mutable<U>[] : T extends object ? { -readonly [K in keyof T]: Mutable<T[K]> } : T
function mutable<T>(value: T): Mutable<T> {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client data is readonly; the TUI store mutates cloned state.
return structuredClone(value) as Mutable<T>
}
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: () => {
@ -288,13 +296,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.next.text.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID)
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.textID,
)
if (match) match.text += event.data.delta
})
break
case "session.next.text.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID)
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.textID,
)
if (match) match.text = event.data.text
})
break
@ -311,19 +325,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.next.tool.input.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status === "pending") match.state.input += event.data.delta
})
break
case "session.next.tool.input.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status === "pending") match.state.input = event.data.text
})
break
case "session.next.tool.called":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (!match) return
match.time.ran = event.data.timestamp
match.provider = event.data.provider
@ -332,7 +355,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.next.tool.progress":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status !== "running") return
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
@ -340,7 +366,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.next.tool.success":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status !== "running") return
match.state = {
status: "completed",
@ -359,7 +388,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.next.tool.failed":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID)
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = {
status: "error",
@ -522,8 +554,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.status[sessionID] ?? "idle"
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
setStore("session", "info", sessionID, result.data.data)
setStore("session", "info", sessionID, mutable(await sdk.api.sessions.get({ sessionID })))
},
message: {
ids(sessionID: string) {
@ -540,11 +571,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(sessionID: string) {
setStore("session", "message", sessionID, [])
messageIndex.set(sessionID, new Map())
const response = await sdk.client.v2.session.messages(
{ sessionID, limit: 200, order: "desc" },
{ throwOnError: true },
)
const loaded = response.data.data.toReversed()
const loaded = mutable(
(await sdk.api.messages.list({ sessionID, limit: 200, order: "desc" })).data,
).toReversed()
const live = store.session.message[sessionID] ?? []
const liveByID = new Map(live.map((message) => [message.id, message]))
const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live]
@ -559,8 +588,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.permission[sessionID]
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true })
setStore("session", "permission", sessionID, result.data.data)
setStore("session", "permission", sessionID, mutable(await sdk.api.permissions.list({ sessionID })))
},
},
question: {
@ -568,8 +596,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.question[sessionID]
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true })
setStore("session", "question", sessionID, result.data.data)
setStore("session", "question", sessionID, mutable(await sdk.api.questions.list({ sessionID })))
},
},
},
@ -579,8 +606,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.project.permission[projectID]
},
async refresh(projectID: string) {
const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true })
setStore("project", "permission", projectID, result.data.data)
setStore("project", "permission", projectID, mutable(await sdk.api.permissions.listSaved({ projectID })))
},
},
},
@ -606,8 +632,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return defaultLocation()
},
async refresh(ref?: LocationRef) {
const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true })
const location = response.data
const location = await sdk.api.location.get({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {})
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
@ -617,9 +642,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.agent
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], agent: result.data.data })
const result = await sdk.api.agents.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], agent: mutable(result.data) })
},
},
command: {
@ -627,9 +652,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.command
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], command: result.data.data })
const result = await sdk.api.commands.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], command: mutable(result.data) })
},
},
integration: {
@ -637,12 +662,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.integration
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.integration.list(
{ location: locationQuery(ref) },
{ throwOnError: true },
)
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], integration: result.data.data })
const result = await sdk.api.integrations.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], integration: mutable(result.data) })
},
},
model: {
@ -650,9 +672,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.model
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], model: result.data.data })
const result = await sdk.api.models.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], model: mutable(result.data) })
},
},
provider: {
@ -660,9 +682,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.provider
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], provider: result.data.data })
const result = await sdk.api.providers.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], provider: mutable(result.data) })
},
},
reference: {
@ -670,9 +692,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], reference: result.data.data })
const result = await sdk.api.references.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], reference: mutable(result.data) })
},
},
skill: {
@ -680,9 +702,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.skill
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], skill: result.data.data })
const result = await sdk.api.skills.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], skill: mutable(result.data) })
},
},
},
@ -690,24 +712,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
const settled = await Promise.allSettled([
sdk.client.v2.session
.list({ limit: 50, order: "desc" }, { throwOnError: true })
sdk.api.sessions
.list({
limit: 50,
order: "desc",
directory: defaultLocation().directory,
workspace: defaultLocation().workspaceID,
})
.then((response) =>
setStore(
"session",
"info",
produce((draft) => {
for (const session of response.data.data) draft[session.id] = session
for (const session of response.data) draft[session.id] = mutable(session)
}),
),
),
sdk.client.v2.session.active({ throwOnError: true }).then((response) =>
setStore(
"session",
"status",
Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])),
sdk.api.sessions
.active()
.then((active) =>
setStore(
"session",
"status",
Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const])),
),
),
),
result.location.refresh(),
result.location.agent.refresh(),
result.location.integration.refresh(),

View file

@ -1,3 +1,4 @@
import type { OpenCodeClient } from "@opencode-ai/client"
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js"
@ -11,9 +12,14 @@ const connectTimeout = 2_000
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { client: OpencodeClient; reload?: () => Promise<OpencodeClient> }) => {
init: (props: {
client: OpencodeClient
api: OpenCodeClient
reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
}) => {
const abort = new AbortController()
let client = props.client
let api = props.api
const events = createGlobalEmitter<SDKEventMap>()
const [connection, setConnection] = createStore<{
status: SDKConnectionStatus
@ -40,7 +46,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
while (!abort.signal.aborted && !controller.signal.aborted) {
const connection = new AbortController()
const cancel = () => connection.abort(controller.signal.reason)
const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout)
const timeout = setTimeout(
() => connection.abort(new Error("Timed out connecting to server")),
connectTimeout,
)
controller.signal.addEventListener("abort", cancel, { once: true })
const error = await (async () => {
const response = await current.v2.event.subscribe({
@ -91,7 +100,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
pending = Promise.resolve()
.then(props.reload)
.then(async (next) => {
client = next
client = next.client
api = next.api
if (!abort.signal.aborted) await start()
})
.finally(() => {
@ -112,6 +122,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get client() {
return client
},
get api() {
return api
},
event: {
on: events.on,
listen: events.listen,