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

@ -98,6 +98,7 @@
}, },
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
@ -581,6 +582,7 @@
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
@ -932,6 +934,7 @@
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.17.11", "version": "1.17.11",
"dependencies": { "dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",

View file

@ -17,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",

View file

@ -3,6 +3,7 @@ import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect" import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
type Transport = { url: string; headers: RequestInit["headers"] } type Transport = { url: string; headers: RequestInit["headers"] }
@ -12,23 +13,23 @@ export function runTui(transport: Transport, reload?: () => Promise<Transport>)
let disposeSlots: (() => void) | undefined let disposeSlots: (() => void) | undefined
return Effect.gen(function* () { return Effect.gen(function* () {
const options = { baseUrl: transport.url, headers: transport.headers } const options = { baseUrl: transport.url, headers: transport.headers }
const client = createOpencodeClient(options) const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => const directory = yield* Effect.tryPromise(() => api.files.list({ location: { directory: process.cwd() } })).pipe(
client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }), Effect.map((response) => response.location.directory),
).pipe(
Effect.map((response) => response.data.location.directory),
Effect.catch(() => Effect.catch(() =>
Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe( Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
Effect.map((response) => response.data.directory),
),
), ),
) )
return yield* run({ return yield* run({
client: createOpencodeClient({ ...options, directory }), client: createOpencodeClient({ ...options, directory }),
api,
reload: reload reload: reload
? async () => { ? async () => {
const next = await reload() const next = await reload()
return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }) return {
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
}
} }
: undefined, : undefined,
args: {}, args: {},

View file

@ -1,2 +1,3 @@
export * from "./generated/index" export * from "./generated/index"
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types" export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>

View file

@ -84,6 +84,7 @@
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",

View file

@ -3,6 +3,7 @@ import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error" import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session" import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({ export const AttachCommand = cmd({
@ -134,6 +135,7 @@ export const AttachCommand = cmd({
await Effect.runPromise( await Effect.runPromise(
run({ run({
client: createOpencodeClient({ baseUrl: args.url, headers, directory }), client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
api: OpenCode.make({ baseUrl: args.url, headers }),
config, config,
pluginHost: createLegacyTuiPluginHost(), pluginHost: createLegacyTuiPluginHost(),
args: { args: {

View file

@ -8,6 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { validateSession } from "../tui/validate-session" import { validateSession } from "../tui/validate-session"
@ -205,6 +206,7 @@ export const TuiThreadCommand = cmd({
await Effect.runPromise( await Effect.runPromise(
run({ run({
client: createOpencodeClient({ baseUrl: url, directory: cwd }), client: createOpencodeClient({ baseUrl: url, directory: cwd }),
api: OpenCode.make({ baseUrl: url }),
async onSnapshot() { async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot") const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined) const server = await client.call("snapshot", undefined)

View file

@ -47,6 +47,7 @@
"./component/spinner": "./src/component/spinner.tsx" "./component/spinner": "./src/component/spinner.tsx"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",

View file

@ -76,6 +76,7 @@ import {
useOpencodeKeymap, useOpencodeKeymap,
} from "./keymap" } from "./keymap"
import type { OpenCodeClient } from "@opencode-ai/client"
import type { OpencodeClient } from "@opencode-ai/sdk/v2" import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
import { createTuiAttention } from "./attention" import { createTuiAttention } from "./attention"
@ -135,7 +136,8 @@ const appBindingCommands = [
export type TuiInput = { export type TuiInput = {
client: OpencodeClient client: OpencodeClient
reload?: () => Promise<OpencodeClient> api: OpenCodeClient
reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
args: Args args: Args
config: TuiConfig.Resolved config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]> onSnapshot?: () => Promise<string[]>
@ -290,7 +292,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
> >
<TuiConfigProvider config={input.config}> <TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}> <PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider client={input.client} reload={input.reload}> <SDKProvider client={input.client} api={input.api} reload={input.reload}>
<ProjectProvider> <ProjectProvider>
<SyncProvider> <SyncProvider>
<DataProvider> <DataProvider>

View file

@ -1,5 +1,6 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2" import type { IntegrationsConnectOauthOutput } from "@opencode-ai/client"
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard" import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data" import { useData } from "../context/data"
@ -22,6 +23,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
} }
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }> type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
type IntegrationAttempt = IntegrationsConnectOauthOutput["data"]
export function integrationOptions(list: IntegrationInfo[]) { export function integrationOptions(list: IntegrationInfo[]) {
return list.toSorted( return list.toSorted(
@ -109,11 +111,8 @@ function manageConnections(
title: `Disconnect ${connection.label}`, title: `Disconnect ${connection.label}`,
value: connection.id, value: connection.id,
onSelect: () => { onSelect: () => {
void sdk.client.v2.credential void sdk.api.credentials
.remove( .remove({ credentialID: connection.id, location: location(data) })
{ credentialID: connection.id, location: location(data) },
{ throwOnError: true },
)
.then(() => disconnected(integration.name, data, dialog, toast)) .then(() => disconnected(integration.name, data, dialog, toast))
.catch(toast.error) .catch(toast.error)
}, },
@ -124,11 +123,7 @@ function manageConnections(
}) })
} }
function selectMethod( function selectMethod(integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType<typeof useDialog>) {
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog) if (methods.length === 1) return openMethod(integration, methods[0], dialog)
dialog.replace(() => ( dialog.replace(() => (
<DialogSelect <DialogSelect
@ -142,11 +137,7 @@ function selectMethod(
)) ))
} }
function openMethod( function openMethod(integration: IntegrationInfo, method: ConnectMethod, dialog: ReturnType<typeof useDialog>) {
integration: IntegrationInfo,
method: ConnectMethod,
dialog: ReturnType<typeof useDialog>,
) {
if (method.type === "key") { if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} />) dialog.replace(() => <KeyMethod integration={integration} method={method} />)
return return
@ -168,21 +159,16 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract<Connec
placeholder="API key" placeholder="API key"
onConfirm={(key) => { onConfirm={(key) => {
if (!key) return if (!key) return
void sdk.client.v2.integration.connect void sdk.api.integrations
.key( .connectKey({
{ integrationID: props.integration.id,
integrationID: props.integration.id, location: location(data),
location: location(data), key,
key, })
},
{ throwOnError: true },
)
.then(() => connected(props.integration.name, data, dialog, toast)) .then(() => connected(props.integration.name, data, dialog, toast))
.catch((cause) => setError(message(cause))) .catch((cause) => setError(message(cause)))
}} }}
description={() => ( description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>}
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
)}
/> />
) )
} }
@ -208,25 +194,22 @@ function OAuthStarting(props: {
const toast = useToast() const toast = useToast()
onMount(() => { onMount(() => {
void sdk.client.v2.integration.connect void sdk.api.integrations
.oauth( .connectOauth({
{ integrationID: props.integration.id,
integrationID: props.integration.id, location: location(data),
location: location(data), methodID: props.method.id,
methodID: props.method.id, inputs: props.inputs,
inputs: props.inputs, })
},
{ throwOnError: true },
)
.then((result) => { .then((result) => {
if (result.data.data.mode === "code") { if (result.data.mode === "code") {
dialog.replace(() => ( dialog.replace(() => (
<OAuthCode integration={props.integration} title={props.method.label} attempt={result.data.data} /> <OAuthCode integration={props.integration} title={props.method.label} attempt={result.data} />
)) ))
return return
} }
dialog.replace(() => ( dialog.replace(() => (
<OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data.data} /> <OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data} />
)) ))
}) })
.catch((cause) => { .catch((cause) => {
@ -265,10 +248,10 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt
})) }))
const poll = () => { const poll = () => {
void sdk.client.v2.integration.attempt void sdk.api.integrations
.status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true }) .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) })
.then((result) => { .then((result) => {
const status = result.data.data const status = result.data
if (status.status === "pending") { if (status.status === "pending") {
timer = setTimeout(poll, 500) timer = setTimeout(poll, 500)
return return
@ -292,7 +275,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt
onCleanup(() => { onCleanup(() => {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
if (settled) return if (settled) return
void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) void sdk.api.integrations.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) })
}) })
return ( return (
@ -317,7 +300,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt
onCleanup(() => { onCleanup(() => {
if (settled) return if (settled) return
void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) void sdk.api.integrations.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) })
}) })
return ( return (
@ -326,11 +309,8 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt
placeholder="Authorization code" placeholder="Authorization code"
onConfirm={(code) => { onConfirm={(code) => {
if (!code) return if (!code) return
void sdk.client.v2.integration.attempt void sdk.api.integrations
.complete( .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code })
{ attemptID: props.attempt.attemptID, location: location(data), code },
{ throwOnError: true },
)
.then(() => { .then(() => {
settled = true settled = true
return connected(props.integration.name, data, dialog, toast) return connected(props.integration.name, data, dialog, toast)
@ -348,13 +328,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt
) )
} }
function OAuthView(props: { function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
title: string
url?: string
instructions?: string
message: string
copy?: boolean
}) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
return ( return (
@ -371,7 +345,9 @@ function OAuthView(props: {
{(url) => ( {(url) => (
<box gap={1}> <box gap={1}>
<Link href={url()} fg={theme.primary} /> <Link href={url()} fg={theme.primary} />
<Show when={props.instructions}>{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}</Show> <Show when={props.instructions}>
{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}
</Show>
</box> </box>
)} )}
</Show> </Show>
@ -436,7 +412,11 @@ async function connected(
dialog: ReturnType<typeof useDialog>, dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>, toast: ReturnType<typeof useToast>,
) { ) {
await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()]) await Promise.all([
data.location.integration.refresh(),
data.location.model.refresh(),
data.location.provider.refresh(),
])
toast.show({ variant: "success", message: `Connected ${name}` }) toast.show({ variant: "success", message: `Connected ${name}` })
dialog.clear() dialog.clear()
} }
@ -447,7 +427,11 @@ async function disconnected(
dialog: ReturnType<typeof useDialog>, dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>, toast: ReturnType<typeof useToast>,
) { ) {
await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()]) await Promise.all([
data.location.integration.refresh(),
data.location.model.refresh(),
data.location.provider.refresh(),
])
toast.show({ variant: "success", message: `Disconnected ${name}` }) toast.show({ variant: "success", message: `Disconnected ${name}` })
dialog.clear() dialog.clear()
} }

View file

@ -11,6 +11,7 @@ import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime" import { useTuiPaths } from "../context/runtime"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
import { isRecord } from "../util/record"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap" import { useCommandShortcut } from "../keymap"
import { useProject } from "../context/project" import { useProject } from "../context/project"
@ -74,10 +75,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
() => (props.initialRemoving ? undefined : props.projectID), () => (props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ProjectDirectory[] | undefined> => { async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
try { try {
await sdk.client.v2.projectCopy.refresh( await sdk.api.projectCopies.refresh({
{ projectID, location: { directory: projectContext.instance.directory() || paths.cwd } }, projectID,
{ throwOnError: true }, location: { directory: projectContext.instance.directory() || paths.cwd },
) })
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
setLoadError(undefined) setLoadError(undefined)
return directories.data ?? [] return directories.data ?? []
@ -221,18 +222,21 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
setToDelete(undefined) setToDelete(undefined)
setRemoving(selected.directory) setRemoving(selected.directory)
setWorking(true) setWorking(true)
const result = await sdk.client.v2.projectCopy const error = await sdk.api.projectCopies
.remove({ .remove({
projectID: props.projectID, projectID: props.projectID,
location: { directory: projectContext.instance.directory() || paths.cwd }, location: { directory: projectContext.instance.directory() || paths.cwd },
directory: selected.directory, directory: selected.directory,
force: false, force: false,
}) })
.catch((error) => ({ error })) .then(
if (result.error) { () => undefined,
(error) => error,
)
if (error) {
setRemoving(undefined) setRemoving(undefined)
setWorking(false) setWorking(false)
if ("data" in result.error && result.error.data.forceRequired) { if (isRecord(error) && isRecord(error.data) && error.data.forceRequired === true) {
const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined) const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined)
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], { const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
title: "Delete working copy?", title: "Delete working copy?",
@ -243,19 +247,22 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return return
} }
reopen(selected.directory) reopen(selected.directory)
const forced = await sdk.client.v2.projectCopy const forcedError = await sdk.api.projectCopies
.remove({ .remove({
projectID: props.projectID, projectID: props.projectID,
location: { directory: projectContext.instance.directory() || paths.cwd }, location: { directory: projectContext.instance.directory() || paths.cwd },
directory: selected.directory, directory: selected.directory,
force: true, force: true,
}) })
.catch((error) => ({ error })) .then(
if (forced.error) { () => undefined,
(error) => error,
)
if (forcedError) {
toast.show({ toast.show({
variant: "error", variant: "error",
title: "Failed to delete project copy", title: "Failed to delete project copy",
message: errorMessage(forced.error), message: errorMessage(forcedError),
}) })
reopen() reopen()
return return
@ -269,7 +276,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
toast.show({ toast.show({
variant: "error", variant: "error",
title: "Failed to delete project copy", title: "Failed to delete project copy",
message: errorMessage(result.error), message: errorMessage(error),
}) })
return return
} }

View file

@ -31,11 +31,16 @@ export function DialogSessionList() {
const [searchResults] = createResource(search, async (query) => { const [searchResults] = createResource(search, async (query) => {
if (!query) return if (!query) return
const response = await sdk.client.v2.session.list( const location = data.location.default()
{ search: query, limit: 50, order: "desc" }, const response = await sdk.api.sessions.list({
{ throwOnError: true }, search: query,
) limit: 50,
return { query, sessions: response.data.data } order: "desc",
directory: location.directory,
workspace: location.workspaceID,
})
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; session list UI reuses legacy mutable session types.
return { query, sessions: structuredClone(response.data) as SessionV2Info[] }
}) })
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
@ -59,7 +64,11 @@ export function DialogSessionList() {
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session])) const sessionMap = new Map(
sessions()
.filter((session) => !session.parentID)
.map((session) => [session.id, session]),
)
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID)) const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinnedSet = new Set(pinned) const pinnedSet = new Set(pinned)
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1])) const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))

View file

@ -17,11 +17,15 @@ export function DialogSessionRename(props: { sessionID: string; currentTitle?: s
onConfirm={(value) => { onConfirm={(value) => {
const title = value.trim() const title = value.trim()
if (!title) return if (!title) return
void sdk.client.v2.session void sdk.api.sessions
.rename({ sessionID: props.sessionID, title }, { throwOnError: true }) .rename({ sessionID: props.sessionID, title })
.then(() => dialog.clear()) .then(() => dialog.clear())
.catch((error) => .catch((error) =>
toast.show({ message: `Failed to rename session: ${errorMessage(error)}`, variant: "error", duration: 5000 }), toast.show({
message: `Failed to rename session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
}),
) )
}} }}
onCancel={() => dialog.clear()} onCancel={() => dialog.clear()}

View file

@ -17,13 +17,15 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
const [files] = createResource( const [files] = createResource(
() => [store.filter], () => [store.filter],
async () => { async () => {
const result = await sdk.client.find.files({ const result = await sdk.api.files
query: store.filter, .find({
workspace: project.workspace.current(), query: store.filter,
}) type: "file",
if (result.error) return [] limit: 5,
const sliced = (result.data ?? []).slice(0, 5) location: { workspace: project.workspace.current() },
return sliced })
.catch(() => undefined)
return result?.data.map((item) => item.path) ?? []
}, },
) )

View file

@ -320,29 +320,26 @@ export function Autocomplete(props: {
if (referenceMatch()) return [] if (referenceMatch()) return []
const { lineRange, baseQuery } = extractLineRange(input.query ?? "") const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
// Get files from SDK const result = await sdk.api.files
const result = await sdk.client.v2.fs.find({ .find({
query: baseQuery, query: baseQuery,
limit: "20", limit: 20,
location: { location: {
directory: input.location?.directory, directory: input.location?.directory,
workspace: input.location?.workspaceID ?? project.workspace.current(), workspace: input.location?.workspaceID ?? project.workspace.current(),
}, },
}) })
.catch(() => undefined)
const options: AutocompleteOption[] = [] const options: AutocompleteOption[] = []
// Add file options. Trust the order returned by fff (frecency, fuzzy // Add file options. Trust the order returned by fff (frecency, fuzzy
// score, filename bonus, etc. are already factored in). // score, filename bonus, etc. are already factored in).
if (!result.error && result.data) { if (result) {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...result.data.data.map((item): AutocompleteOption => { ...result.data.map((item): AutocompleteOption => {
const { filename, part } = createFilePart( const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
item,
path.join(result.data.location.directory, item.path),
lineRange,
)
return { return {
display: Locale.truncateMiddle(filename, width), display: Locale.truncateMiddle(filename, width),
value: filename, value: filename,

View file

@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash" import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import type { AssistantMessage, FilePart, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error" import { errorMessage } from "../../util/error"
import { createColors, createFrames } from "../../ui/spinner" import { createColors, createFrames } from "../../ui/spinner"
@ -158,11 +158,12 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog() const dialog = useDialog()
const toast = useToast() const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? "")) const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const activeSubagents = createMemo(() => const activeSubagents = createMemo(
data.session () =>
.list() data.session
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running") .list()
.length, .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
.length,
) )
const runningShells = createMemo( const runningShells = createMemo(
() => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length, () => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length,
@ -424,7 +425,7 @@ export function Prompt(props: PromptProps) {
}, 5000) }, 5000)
if (store.interrupt >= 2) { if (store.interrupt >= 2) {
void sdk.client.v2.session.interrupt({ void sdk.api.sessions.interrupt({
sessionID: props.sessionID, sessionID: props.sessionID,
}) })
setStore("interrupt", 0) setStore("interrupt", 0)
@ -1009,18 +1010,23 @@ export function Prompt(props: PromptProps) {
const directory = await move.getDirectory(store.prompt.input) const directory = await move.getDirectory(store.prompt.input)
if (move.pending() && !directory) return false if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress()) finishMoveProgress = Boolean(move.progress())
const location = data.location.default()
const res = await sdk.client.v2.session.create({ const created = await sdk.api.sessions
location: directory ? { directory, workspaceID } : undefined, .create({
agent: agent.id, location: directory
model: { ? { directory, workspaceID }
providerID: selectedModel.providerID, : { directory: location.directory, workspaceID: workspaceID ?? location.workspaceID },
id: selectedModel.modelID, agent: agent.id,
variant, model: {
}, providerID: selectedModel.providerID,
}) id: selectedModel.modelID,
variant,
},
})
.catch(() => undefined)
if (res.error) { if (!created) {
if (finishMoveProgress) move.finishSubmit() if (finishMoveProgress) move.finishSubmit()
toast.show({ toast.show({
message: "Creating a session failed. Open console for more details.", message: "Creating a session failed. Open console for more details.",
@ -1030,8 +1036,9 @@ export function Prompt(props: PromptProps) {
return true return true
} }
sessionID = res.data.data.id sessionID = created.id
session = res.data.data // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types.
session = structuredClone(created) as SessionV2Info
} }
const inputText = expandTrackedPastedText( const inputText = expandTrackedPastedText(
@ -1107,65 +1114,70 @@ export function Prompt(props: PromptProps) {
session = data.session.get(sessionID) session = data.session.get(sessionID)
} }
if (session?.agent !== agent.id) { if (session?.agent !== agent.id) {
await sdk.client.v2.session.switchAgent({ sessionID, agent: agent.id }, { throwOnError: true }) await sdk.api.sessions.switchAgent({ sessionID, agent: agent.id })
} }
if ( if (
session?.model?.providerID !== selectedModel.providerID || session?.model?.providerID !== selectedModel.providerID ||
session.model.id !== selectedModel.modelID || session.model.id !== selectedModel.modelID ||
session.model.variant !== variant session.model.variant !== variant
) { ) {
await sdk.client.v2.session.switchModel( await sdk.api.sessions.switchModel({
{ sessionID,
sessionID, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, })
},
{ throwOnError: true },
)
} }
if (session?.revert) { if (session?.revert) {
const revertResult = await sdk.client.v2.session.revert.commit({ sessionID }) const error = await sdk.api.sessions.commit({ sessionID }).then(
if (revertResult.error) { () => undefined,
toast.show({ title: "Failed to commit revert", message: errorMessage(revertResult.error), variant: "error" }) (error) => error,
)
if (error) {
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
return false return false
} }
} }
const result = await sdk.client.v2.session.prompt({ const error = await sdk.api.sessions
sessionID, .prompt({
prompt: { sessionID,
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), prompt: {
files: nonTextParts.flatMap((part) => text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
part.type === "file" files: nonTextParts.flatMap((part) =>
? [ part.type === "file"
{ ? [
uri: part.url, {
name: part.filename, uri: part.url,
source: part.source name: part.filename,
? { source: part.source
start: part.source.text.start, ? {
end: part.source.text.end, start: part.source.text.start,
text: part.source.text.value, end: part.source.text.end,
} text: part.source.text.value,
: undefined, }
}, : undefined,
] },
: [], ]
), : [],
agents: nonTextParts.flatMap((part) => ),
part.type === "agent" agents: nonTextParts.flatMap((part) =>
? [ part.type === "agent"
{ ? [
name: part.name, {
source: part.source name: part.name,
? { start: part.source.start, end: part.source.end, text: part.source.value } source: part.source
: undefined, ? { start: part.source.start, end: part.source.end, text: part.source.value }
}, : undefined,
] },
: [], ]
), : [],
}, ),
}) },
if (result.error) { })
toast.show({ title: "Failed to send prompt", message: errorMessage(result.error), variant: "error" }) .then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
return false return false
} }
if (editorParts.length > 0) editor.markSelectionSent() if (editorParts.length > 0) editor.markSelectionSent()

View file

@ -37,17 +37,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
{ projectID, context }, { projectID, context },
{ throwOnError: true }, { throwOnError: true },
) )
const result = await sdk.client.v2.projectCopy.create( const result = await sdk.api.projectCopies.create({
{ projectID,
projectID, location: { directory: project.instance.directory() || paths.cwd },
location: { directory: project.instance.directory() || paths.cwd }, strategy: "git_worktree",
strategy: "git_worktree", directory: path.join(paths.worktree, projectID.slice(0, 6)),
directory: path.join(paths.worktree, projectID.slice(0, 6)), name: generated.data.name,
name: generated.data.name, })
}, const directory = result.directory
{ throwOnError: true },
)
const directory = result.data?.directory
if (!directory) throw new Error("No project copy directory returned") if (!directory) throw new Error("No project copy directory returned")
// Call a location-based route to make sure it's bootstrapped // Call a location-based route to make sure it's bootstrapped

View file

@ -61,6 +61,14 @@ function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined 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({ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data", name: "Data",
init: () => { init: () => {
@ -288,13 +296,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break break
case "session.next.text.delta": case "session.next.text.delta":
message.update(event.data.sessionID, (draft, index) => { 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 if (match) match.text += event.data.delta
}) })
break break
case "session.next.text.ended": case "session.next.text.ended":
message.update(event.data.sessionID, (draft, index) => { 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 if (match) match.text = event.data.text
}) })
break break
@ -311,19 +325,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break break
case "session.next.tool.input.delta": case "session.next.tool.input.delta":
message.update(event.data.sessionID, (draft, index) => { 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 if (match?.state.status === "pending") match.state.input += event.data.delta
}) })
break break
case "session.next.tool.input.ended": case "session.next.tool.input.ended":
message.update(event.data.sessionID, (draft, index) => { 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 if (match?.state.status === "pending") match.state.input = event.data.text
}) })
break break
case "session.next.tool.called": case "session.next.tool.called":
message.update(event.data.sessionID, (draft, index) => { 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 if (!match) return
match.time.ran = event.data.timestamp match.time.ran = event.data.timestamp
match.provider = event.data.provider match.provider = event.data.provider
@ -332,7 +355,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break break
case "session.next.tool.progress": case "session.next.tool.progress":
message.update(event.data.sessionID, (draft, index) => { 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 if (match?.state.status !== "running") return
match.state.structured = event.data.structured match.state.structured = event.data.structured
match.state.content = [...event.data.content] match.state.content = [...event.data.content]
@ -340,7 +366,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break break
case "session.next.tool.success": case "session.next.tool.success":
message.update(event.data.sessionID, (draft, index) => { 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 if (match?.state.status !== "running") return
match.state = { match.state = {
status: "completed", status: "completed",
@ -359,7 +388,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break break
case "session.next.tool.failed": case "session.next.tool.failed":
message.update(event.data.sessionID, (draft, index) => { 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 if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = { match.state = {
status: "error", status: "error",
@ -522,8 +554,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.status[sessionID] ?? "idle" return store.session.status[sessionID] ?? "idle"
}, },
async refresh(sessionID: string) { async refresh(sessionID: string) {
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true }) setStore("session", "info", sessionID, mutable(await sdk.api.sessions.get({ sessionID })))
setStore("session", "info", sessionID, result.data.data)
}, },
message: { message: {
ids(sessionID: string) { ids(sessionID: string) {
@ -540,11 +571,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async refresh(sessionID: string) { async refresh(sessionID: string) {
setStore("session", "message", sessionID, []) setStore("session", "message", sessionID, [])
messageIndex.set(sessionID, new Map()) messageIndex.set(sessionID, new Map())
const response = await sdk.client.v2.session.messages( const loaded = mutable(
{ sessionID, limit: 200, order: "desc" }, (await sdk.api.messages.list({ sessionID, limit: 200, order: "desc" })).data,
{ throwOnError: true }, ).toReversed()
)
const loaded = response.data.data.toReversed()
const live = store.session.message[sessionID] ?? [] const live = store.session.message[sessionID] ?? []
const liveByID = new Map(live.map((message) => [message.id, message])) const liveByID = new Map(live.map((message) => [message.id, message]))
const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live] 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] return store.session.permission[sessionID]
}, },
async refresh(sessionID: string) { async refresh(sessionID: string) {
const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true }) setStore("session", "permission", sessionID, mutable(await sdk.api.permissions.list({ sessionID })))
setStore("session", "permission", sessionID, result.data.data)
}, },
}, },
question: { question: {
@ -568,8 +596,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.question[sessionID] return store.session.question[sessionID]
}, },
async refresh(sessionID: string) { async refresh(sessionID: string) {
const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true }) setStore("session", "question", sessionID, mutable(await sdk.api.questions.list({ sessionID })))
setStore("session", "question", sessionID, result.data.data)
}, },
}, },
}, },
@ -579,8 +606,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.project.permission[projectID] return store.project.permission[projectID]
}, },
async refresh(projectID: string) { async refresh(projectID: string) {
const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true }) setStore("project", "permission", projectID, mutable(await sdk.api.permissions.listSaved({ projectID })))
setStore("project", "permission", projectID, result.data.data)
}, },
}, },
}, },
@ -606,8 +632,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return defaultLocation() return defaultLocation()
}, },
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true }) const location = await sdk.api.location.get({ location: locationQuery(ref ?? defaultLocation()) })
const location = response.data
const key = locationKey(location) const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {}) if (!store.location[key]) setStore("location", key, {})
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) 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 return store.location[locationKey(location ?? defaultLocation())]?.agent
}, },
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.api.agents.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.data.location) const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], agent: result.data.data }) setStore("location", key, { ...store.location[key], agent: mutable(result.data) })
}, },
}, },
command: { command: {
@ -627,9 +652,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.command return store.location[locationKey(location ?? defaultLocation())]?.command
}, },
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.api.commands.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.data.location) const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], command: result.data.data }) setStore("location", key, { ...store.location[key], command: mutable(result.data) })
}, },
}, },
integration: { integration: {
@ -637,12 +662,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.integration return store.location[locationKey(location ?? defaultLocation())]?.integration
}, },
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.integration.list( const result = await sdk.api.integrations.list({ location: locationQuery(ref ?? defaultLocation()) })
{ location: locationQuery(ref) }, const key = locationKey(result.location)
{ throwOnError: true }, setStore("location", key, { ...store.location[key], integration: mutable(result.data) })
)
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], integration: result.data.data })
}, },
}, },
model: { model: {
@ -650,9 +672,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.model return store.location[locationKey(location ?? defaultLocation())]?.model
}, },
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.api.models.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.data.location) const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], model: result.data.data }) setStore("location", key, { ...store.location[key], model: mutable(result.data) })
}, },
}, },
provider: { provider: {
@ -660,9 +682,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.provider return store.location[locationKey(location ?? defaultLocation())]?.provider
}, },
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.api.providers.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.data.location) const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], provider: result.data.data }) setStore("location", key, { ...store.location[key], provider: mutable(result.data) })
}, },
}, },
reference: { reference: {
@ -670,9 +692,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.reference return store.location[locationKey(location ?? defaultLocation())]?.reference
}, },
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.api.references.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.data.location) const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], reference: result.data.data }) setStore("location", key, { ...store.location[key], reference: mutable(result.data) })
}, },
}, },
skill: { skill: {
@ -680,9 +702,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.location[locationKey(location ?? defaultLocation())]?.skill return store.location[locationKey(location ?? defaultLocation())]?.skill
}, },
async refresh(ref?: LocationRef) { async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true }) const result = await sdk.api.skills.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.data.location) const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], skill: result.data.data }) setStore("location", key, { ...store.location[key], skill: mutable(result.data) })
}, },
}, },
}, },
@ -690,24 +712,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() { async function bootstrap() {
const settled = await Promise.allSettled([ const settled = await Promise.allSettled([
sdk.client.v2.session sdk.api.sessions
.list({ limit: 50, order: "desc" }, { throwOnError: true }) .list({
limit: 50,
order: "desc",
directory: defaultLocation().directory,
workspace: defaultLocation().workspaceID,
})
.then((response) => .then((response) =>
setStore( setStore(
"session", "session",
"info", "info",
produce((draft) => { 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) => sdk.api.sessions
setStore( .active()
"session", .then((active) =>
"status", setStore(
Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])), "session",
"status",
Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const])),
),
), ),
),
result.location.refresh(), result.location.refresh(),
result.location.agent.refresh(), result.location.agent.refresh(),
result.location.integration.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 type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js" import { onCleanup, onMount } from "solid-js"
@ -11,9 +12,14 @@ const connectTimeout = 2_000
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", 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() const abort = new AbortController()
let client = props.client let client = props.client
let api = props.api
const events = createGlobalEmitter<SDKEventMap>() const events = createGlobalEmitter<SDKEventMap>()
const [connection, setConnection] = createStore<{ const [connection, setConnection] = createStore<{
status: SDKConnectionStatus status: SDKConnectionStatus
@ -40,7 +46,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
while (!abort.signal.aborted && !controller.signal.aborted) { while (!abort.signal.aborted && !controller.signal.aborted) {
const connection = new AbortController() const connection = new AbortController()
const cancel = () => connection.abort(controller.signal.reason) 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 }) controller.signal.addEventListener("abort", cancel, { once: true })
const error = await (async () => { const error = await (async () => {
const response = await current.v2.event.subscribe({ const response = await current.v2.event.subscribe({
@ -91,7 +100,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
pending = Promise.resolve() pending = Promise.resolve()
.then(props.reload) .then(props.reload)
.then(async (next) => { .then(async (next) => {
client = next client = next.client
api = next.api
if (!abort.signal.aborted) await start() if (!abort.signal.aborted) await start()
}) })
.finally(() => { .finally(() => {
@ -112,6 +122,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get client() { get client() {
return client return client
}, },
get api() {
return api
},
event: { event: {
on: events.on, on: events.on,
listen: events.listen, listen: events.listen,

View file

@ -11,9 +11,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
const clipboard = useClipboard() const clipboard = useClipboard()
const toast = useToast() const toast = useToast()
const sdk = useSDK() const sdk = useSDK()
const message = createMemo(() => const message = createMemo(() => data.session.message.get(props.sessionID, props.messageID))
data.session.message.get(props.sessionID, props.messageID),
)
return ( return (
<DialogSelect <DialogSelect
@ -24,11 +22,9 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
value: "session.revert", value: "session.revert",
description: "undo messages and file changes", description: "undo messages and file changes",
onSelect: async (dialog) => { onSelect: async (dialog) => {
const result = await sdk.client.v2.session.revert.stage({ await sdk.api.sessions
sessionID: props.sessionID, .stage({ sessionID: props.sessionID, messageID: props.messageID })
messageID: props.messageID, .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
})
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
dialog.clear() dialog.clear()
}, },
}, },

View file

@ -156,10 +156,11 @@ export function Session() {
const promptRef = usePromptRef() const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID)) const session = createMemo(() => data.session.get(route.sessionID))
const messageIDs = createMemo(() => data.session.message.ids(route.sessionID)) const messageIDs = createMemo(() => data.session.message.ids(route.sessionID))
const sessionMessages = () => messageIDs().flatMap((id) => { const sessionMessages = () =>
const message = data.session.message.get(route.sessionID, id) messageIDs().flatMap((id) => {
return message ? [message] : [] const message = data.session.message.get(route.sessionID, id)
}) return message ? [message] : []
})
const location = createMemo(() => session()?.location) const location = createMemo(() => session()?.location)
createEffect(() => { createEffect(() => {
@ -286,7 +287,10 @@ export function Session() {
if (!message) return false if (!message) return false
if (message.type === "user") return Boolean(message.text.trim()) if (message.type === "user") return Boolean(message.text.trim())
return message.type === "assistant" && message.content.some((content) => content.type === "text" && content.text.trim()) return (
message.type === "assistant" &&
message.content.some((content) => content.type === "text" && content.text.trim())
)
}) })
.sort((a, b) => a.y - b.y) .sort((a, b) => a.y - b.y)
@ -361,7 +365,7 @@ export function Session() {
aliases: ["summarize"], aliases: ["summarize"],
}, },
run: () => { run: () => {
void sdk.client.v2.session.compact({ sessionID: route.sessionID }) void sdk.api.sessions.compact({ sessionID: route.sessionID })
dialog.clear() dialog.clear()
}, },
}, },
@ -395,8 +399,11 @@ export function Session() {
dialog.clear() dialog.clear()
return return
} }
const result = await sdk.client.v2.session.revert.stage({ sessionID: route.sessionID, messageID: target }) const error = await sdk.api.sessions.stage({ sessionID: route.sessionID, messageID: target }).then(
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) () => undefined,
(error) => error,
)
if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
dialog.clear() dialog.clear()
})() })()
}, },
@ -409,8 +416,11 @@ export function Session() {
slash: { name: "redo" }, slash: { name: "redo" },
run: () => { run: () => {
void (async () => { void (async () => {
const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID }) const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then(
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) () => undefined,
(error) => error,
)
if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
dialog.clear() dialog.clear()
})() })()
}, },
@ -878,23 +888,21 @@ export function Session() {
</For> </For>
<Show when={session()?.revert?.messageID}> <Show when={session()?.revert?.messageID}>
<RevertMessage <RevertMessage
count={messages().filter((message) => message.id >= session()!.revert!.messageID && message.type === "user").length} count={
messages().filter(
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
).length
}
files={session()!.revert!.files ?? []} files={session()!.revert!.files ?? []}
/> />
</Show> </Show>
</scrollbox> </scrollbox>
<box flexShrink={0}> <box flexShrink={0}>
<Show when={permissions().length > 0}> <Show when={permissions().length > 0}>
<PermissionPrompt <PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
request={permissions()[0]}
directory={session()?.location.directory}
/>
</Show> </Show>
<Show when={permissions().length === 0 && questions().length > 0}> <Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt <QuestionPrompt request={questions()[0]} directory={session()?.location.directory} />
request={questions()[0]}
directory={session()?.location.directory}
/>
</Show> </Show>
<Show when={session()?.parentID}> <Show when={session()?.parentID}>
<SubagentFooter /> <SubagentFooter />
@ -957,9 +965,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
<Switch> <Switch>
<Match when={props.row.type === "message" ? props.row : undefined}> <Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => ( {(row) => (
<Show when={props.message(row().messageID)}> <Show when={props.message(row().messageID)}>{(message) => <SessionMessageView message={message()} />}</Show>
{(message) => <SessionMessageView message={message()} />}
</Show>
)} )}
</Match> </Match>
<Match when={props.row.type === "part" ? props.row : undefined}> <Match when={props.row.type === "part" ? props.row : undefined}>
@ -1073,8 +1079,8 @@ function SessionGroupView(props: {
result[name] = (result[name] ?? 0) + 1 result[name] = (result[name] ?? 0) + 1
return result return result
}, {}) }, {})
const tools = Object.entries(counts).map(([name, count]) => const tools = Object.entries(counts).map(
`${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, ([name, count]) => `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`,
) )
return `${props.completed ? "Explored" : "Exploring"}${tools.join(", ")}` return `${props.completed ? "Explored" : "Exploring"}${tools.join(", ")}`
}) })
@ -1116,9 +1122,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
const { theme } = useTheme() const { theme } = useTheme()
const model = createMemo( const model = createMemo(
() => () =>
ctx.models().find( ctx
(model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, .models()
)?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, .find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
) )
const duration = createMemo(() => const duration = createMemo(() =>
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0, props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
@ -1158,14 +1165,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) {
function CompactionMessage() { function CompactionMessage() {
const { theme } = useTheme() const { theme } = useTheme()
return ( return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
<box
border={["top"]}
title=" Compaction "
titleAlignment="center"
borderColor={theme.borderActive}
/>
)
} }
function statusLabel(status: "added" | "modified" | "deleted") { function statusLabel(status: "added" | "modified" | "deleted") {
@ -1197,8 +1197,11 @@ function RevertMessage(props: {
onMouseUp={() => { onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return if (renderer.getSelection()?.getSelectedText()) return
void (async () => { void (async () => {
const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID }) const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then(
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) () => undefined,
(error) => error,
)
if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
})() })()
}} }}
flexShrink={0} flexShrink={0}
@ -1207,7 +1210,12 @@ function RevertMessage(props: {
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundPanel} borderColor={theme.backgroundPanel}
> >
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}> <box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.textMuted}> <text fg={theme.textMuted}>
{props.count} message{props.count === 1 ? "" : "s"} reverted {props.count} message{props.count === 1 ? "" : "s"} reverted
</text> </text>
@ -1251,54 +1259,63 @@ function UserMessage(props: { message: SessionMessageUser }) {
return ( return (
<Show when={props.message.text.trim() || files().length}> <Show when={props.message.text.trim() || files().length}>
<box
id={props.message.id}
border={["left"]}
borderColor={color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box <box
id={props.message.id} onMouseOver={() => {
border={["left"]} setHover(true)
borderColor={color()} }}
customBorderChars={SplitBorder.customBorderChars} onMouseOut={() => {
setHover(false)
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />)
}}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
> >
<box <text fg={theme.text}>{props.message.text}</text>
onMouseOver={() => { <Show when={files().length}>
setHover(true) <box
}} flexDirection="row"
onMouseOut={() => { paddingBottom={ctx.showTimestamps() ? 1 : 0}
setHover(false) paddingTop={1}
}} gap={1}
onMouseUp={() => { flexWrap="wrap"
if (renderer.getSelection()?.getSelectedText()) return >
dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />) <For each={files()}>
}} {(file) => {
paddingTop={1} const directory = file.mime === "application/x-directory"
paddingBottom={1} return (
paddingLeft={2} <text fg={theme.text}>
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} <span style={{ bg: theme.secondary, fg: theme.background }}>
flexShrink={0} {directory ? " Directory " : " File "}
> </span>
<text fg={theme.text}>{props.message.text}</text> <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
<Show when={files().length}> {" "}
<box flexDirection="row" paddingBottom={ctx.showTimestamps() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap"> {file.name ?? file.uri}{" "}
<For each={files()}> </span>
{(file) => { </text>
const directory = file.mime === "application/x-directory" )
return ( }}
<text fg={theme.text}> </For>
<span style={{ bg: theme.secondary, fg: theme.background }}> </box>
{directory ? " Directory " : " File "} </Show>
</span> <Show when={ctx.showTimestamps()}>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span> <text fg={theme.textMuted}>
</text> <span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
) </text>
}} </Show>
</For>
</box>
</Show>
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
</text>
</Show>
</box>
</box> </box>
</box>
</Show> </Show>
) )
} }
@ -1309,9 +1326,10 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
const { theme } = useTheme() const { theme } = useTheme()
const model = createMemo( const model = createMemo(
() => () =>
ctx.models().find( ctx
(model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, .models()
)?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, .find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
) )
const final = createMemo(() => { const final = createMemo(() => {
@ -1325,10 +1343,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
}) })
const exploration = createMemo(() => { const exploration = createMemo(() => {
const grouped = new Map< const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
string,
{ first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }
>()
if (!ctx.groupExploration()) return grouped if (!ctx.groupExploration()) return grouped
const runs = props.message.content const runs = props.message.content
.map((part) => .map((part) =>
@ -1471,7 +1486,9 @@ function ReasoningPart(props: {
// OpenRouter encrypts some reasoning blocks; drop the placeholder. // OpenRouter encrypts some reasoning blocks; drop the placeholder.
return props.part.text.replace("[REDACTED]", "").trim() return props.part.text.replace("[REDACTED]", "").trim()
}) })
const isDone = createMemo(() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined) const isDone = createMemo(
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => { const duration = createMemo(() => {
const end = props.part.time?.completed ?? props.message.time.completed const end = props.part.time?.completed ?? props.message.time.completed
@ -1488,11 +1505,7 @@ function ReasoningPart(props: {
return ( return (
<Show when={content()}> <Show when={content()}>
<box <box paddingLeft={3} flexDirection="column" flexShrink={0}>
paddingLeft={3}
flexDirection="column"
flexShrink={0}
>
<box onMouseUp={toggle}> <box onMouseUp={toggle}>
<ReasoningHeader <ReasoningHeader
toggleable={inMinimal()} toggleable={inMinimal()}
@ -1808,12 +1821,7 @@ export function InlineToolRow(props: {
onMouseUp?: () => void onMouseUp?: () => void
}) { }) {
return ( return (
<box <box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
paddingLeft={3}
onMouseOver={props.onMouseOver}
onMouseOut={props.onMouseOut}
onMouseUp={props.onMouseUp}
>
<Switch> <Switch>
<Match when={props.spinner}> <Match when={props.spinner}>
<Spinner color={props.color} children={props.children} /> <Spinner color={props.color} children={props.children} />
@ -2003,12 +2011,7 @@ function Write(props: ToolProps) {
</BlockTool> </BlockTool>
</Match> </Match>
<Match when={true}> <Match when={true}>
<InlineTool <InlineTool icon="←" pending="Preparing write..." complete={stringValue(props.input.path)} part={props.part}>
icon="←"
pending="Preparing write..."
complete={stringValue(props.input.path)}
part={props.part}
>
Write {pathFormatter.format(stringValue(props.input.path))} Write {pathFormatter.format(stringValue(props.input.path))}
</InlineTool> </InlineTool>
</Match> </Match>
@ -2473,7 +2476,14 @@ export function parseApplyPatchFiles(value: unknown) {
const patch = stringValue(file.patch) const patch = stringValue(file.patch)
const additions = numberValue(file.additions) const additions = numberValue(file.additions)
const deletions = numberValue(file.deletions) const deletions = numberValue(file.deletions)
if (!type || !relativePath || !filePath || patch === undefined || additions === undefined || deletions === undefined) if (
!type ||
!relativePath ||
!filePath ||
patch === undefined ||
additions === undefined ||
deletions === undefined
)
return [] return []
return [{ type, relativePath, filePath, patch, additions, deletions, movePath: stringValue(file.movePath) }] return [{ type, relativePath, filePath, patch, additions, deletions, movePath: stringValue(file.movePath) }]
}) })

View file

@ -186,7 +186,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
onSelect={(option) => { onSelect={(option) => {
setStore("stage", "permission") setStore("stage", "permission")
if (option === "cancel") return if (option === "cancel") return
void sdk.client.v2.session.permission.reply({ void sdk.api.permissions.reply({
sessionID: props.request.sessionID, sessionID: props.request.sessionID,
reply: "always", reply: "always",
requestID: props.request.id, requestID: props.request.id,
@ -197,7 +197,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
<Match when={store.stage === "reject"}> <Match when={store.stage === "reject"}>
<RejectPrompt <RejectPrompt
onConfirm={(message) => { onConfirm={(message) => {
void sdk.client.v2.session.permission.reply({ void sdk.api.permissions.reply({
sessionID: props.request.sessionID, sessionID: props.request.sessionID,
reply: "reject", reply: "reject",
requestID: props.request.id, requestID: props.request.id,
@ -443,14 +443,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
setStore("stage", "reject") setStore("stage", "reject")
return return
} }
void sdk.client.v2.session.permission.reply({ void sdk.api.permissions.reply({
sessionID: props.request.sessionID, sessionID: props.request.sessionID,
reply: "reject", reply: "reject",
requestID: props.request.id, requestID: props.request.id,
}) })
return return
} }
void sdk.client.v2.session.permission.reply({ void sdk.api.permissions.reply({
sessionID: props.request.sessionID, sessionID: props.request.sessionID,
reply: "once", reply: "once",
requestID: props.request.id, requestID: props.request.id,

View file

@ -47,15 +47,15 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
function submit() { function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? []) const answers = questions().map((_, i) => store.answers[i] ?? [])
void sdk.client.v2.session.question.reply({ void sdk.api.questions.reply({
sessionID: props.request.sessionID, sessionID: props.request.sessionID,
requestID: props.request.id, requestID: props.request.id,
questionV2Reply: { answers }, answers,
}) })
} }
function reject() { function reject() {
void sdk.client.v2.session.question.reject({ void sdk.api.questions.reject({
sessionID: props.request.sessionID, sessionID: props.request.sessionID,
requestID: props.request.id, requestID: props.request.id,
}) })
@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?:
setStore("custom", inputs) setStore("custom", inputs)
} }
if (single()) { if (single()) {
void sdk.client.v2.session.question.reply({ void sdk.api.questions.reply({
sessionID: props.request.sessionID, sessionID: props.request.sessionID,
requestID: props.request.id, requestID: props.request.id,
questionV2Reply: { answers: [[answer]] }, answers: [[answer]],
}) })
return return
} }

View file

@ -4,7 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect" import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { createTuiResolvedConfig } from "./fixture/tui-runtime" import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk" import { createApi, createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
test("SIGHUP clears title and disposes scoped resources once", async () => { test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@ -30,6 +30,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
const task = Effect.runPromise( const task = Effect.runPromise(
run({ run({
client: createClient(calls.fetch), client: createClient(calls.fetch),
api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }), config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: {}, args: {},
pluginHost: { pluginHost: {
@ -61,26 +62,23 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
const core = await import("@opentui/core") const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream() const events = createEventStream()
const calls = createFetch( const calls = createFetch((url) => {
(url) => { if (url.pathname === "/api/session")
if (url.pathname === "/api/session") return json({
return json({ data: [
data: [ {
{ id: "dummy",
id: "dummy", title: "Demo session",
title: "Demo session", projectID: "project",
projectID: "project", location: { directory },
location: { directory }, cost: 0,
cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, updated: 0 },
time: { created: 0, updated: 0 }, },
}, ],
], cursor: {},
cursor: {}, })
}) }, events)
},
events,
)
const originalWrite = process.stdout.write.bind(process.stdout) const originalWrite = process.stdout.write.bind(process.stdout)
let stdout = "" let stdout = ""
let api: TuiPluginApi | undefined let api: TuiPluginApi | undefined
@ -99,6 +97,7 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
const task = Effect.runPromise( const task = Effect.runPromise(
run({ run({
client: createClient(calls.fetch), client: createClient(calls.fetch),
api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }), config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: { continue: true }, args: { continue: true },
pluginHost: { pluginHost: {

View file

@ -7,7 +7,7 @@ import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk" import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync" import { SyncProvider, useSync } from "../../../../src/context/sync"
import { ExitProvider } from "../../../../src/context/exit" import { ExitProvider } from "../../../../src/context/exit"
import { createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk" import { createApi, createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment" import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk" export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) {
<TestTuiContexts paths={state ? { state } : undefined}> <TestTuiContexts paths={state ? { state } : undefined}>
<ArgsProvider> <ArgsProvider>
<KVProvider> <KVProvider>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<ExitProvider exit={() => {}}> <ExitProvider exit={() => {}}>
<SyncProvider> <SyncProvider>

View file

@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project" import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk" import { SDKProvider } from "../../../src/context/sdk"
import { DataProvider, useData } from "../../../src/context/data" import { DataProvider, useData } from "../../../src/context/data"
import { createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
async function wait(fn: () => boolean, timeout = 2000) { async function wait(fn: () => boolean, timeout = 2000) {
@ -69,7 +69,7 @@ test("refreshes resources into reactive getters", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -139,7 +139,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -172,8 +172,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
test("tracks session status from active sessions and execution events", async () => { test("tracks session status from active sessions and execution events", async () => {
const events = createEventStream() const events = createEventStream()
const calls = createFetch((url) => { const calls = createFetch((url) => {
if (url.pathname === "/api/session/active") if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
return json({ data: { "session-active": { type: "running" } } })
}, events) }, events)
let data!: ReturnType<typeof useData> let data!: ReturnType<typeof useData>
@ -184,7 +183,7 @@ test("tracks session status from active sessions and execution events", async ()
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -296,7 +295,7 @@ test("refreshes integrations after integration updates", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -337,7 +336,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<box /> <box />
@ -382,7 +381,7 @@ test("refreshes references after updates", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -415,7 +414,7 @@ test("adds and dismisses permission requests from live events", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -480,7 +479,7 @@ test("adds and dismisses question requests from live events", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -548,7 +547,7 @@ test("settles pending tools when a live failure arrives", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -677,7 +676,7 @@ test("renders admitted prompts only after they become model-visible", async () =
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />
@ -750,7 +749,7 @@ test("projects live context updates with their message ID", async () => {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
<Probe /> <Probe />

View file

@ -1,12 +1,13 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpenCodeClient } from "@opencode-ai/client"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2" import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/context/project" import { ProjectProvider, useProject } from "../../../src/context/project"
import { SDKProvider, useSDK } from "../../../src/context/sdk" import { SDKProvider, useSDK } from "../../../src/context/sdk"
import { useEvent } from "../../../src/context/event" import { useEvent } from "../../../src/context/event"
import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk" import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
const projectID = "proj_test" const projectID = "proj_test"
@ -46,7 +47,7 @@ function update(version: string): V2Event {
} }
} }
async function mount(reload?: () => Promise<OpencodeClient>) { async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) {
const events = createEventStream() const events = createEventStream()
const calls = createFetch(undefined, events) const calls = createFetch(undefined, events)
const seen: V2Event[] = [] const seen: V2Event[] = []
@ -60,7 +61,7 @@ async function mount(reload?: () => Promise<OpencodeClient>) {
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} reload={reload}> <SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} reload={reload}>
<ProjectProvider> <ProjectProvider>
<Probe <Probe
onReady={async (ctx) => { onReady={async (ctx) => {
@ -150,7 +151,8 @@ describe("useEvent", () => {
test("reloads the host and reconnects the event stream", async () => { test("reloads the host and reconnects the event stream", async () => {
let calls = 0 let calls = 0
const events = createEventStream() const events = createEventStream()
const replacement = createClient(createFetch(undefined, events).fetch) const replacementCalls = createFetch(undefined, events)
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
const { app, sdk, seen } = await mount(async () => { const { app, sdk, seen } = await mount(async () => {
calls += 1 calls += 1
return replacement return replacement
@ -164,19 +166,21 @@ describe("useEvent", () => {
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded")) await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded"))
expect(calls).toBe(1) expect(calls).toBe(1)
expect(sdk.client).toBe(replacement) expect(sdk.client).toBe(replacement.client)
expect(sdk.api).toBe(replacement.api)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
}) })
test("keeps the current event stream alive while the host reload is pending", async () => { test("keeps the current event stream alive while the host reload is pending", async () => {
let complete!: (client: OpencodeClient) => void let complete!: (client: { client: OpencodeClient; api: OpenCodeClient }) => void
const pending = new Promise<OpencodeClient>((resolve) => { const pending = new Promise<{ client: OpencodeClient; api: OpenCodeClient }>((resolve) => {
complete = resolve complete = resolve
}) })
const replacementEvents = createEventStream() const replacementEvents = createEventStream()
const replacement = createClient(createFetch(undefined, replacementEvents).fetch) const replacementCalls = createFetch(undefined, replacementEvents)
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
const { app, emit, sdk, seen } = await mount(() => pending) const { app, emit, sdk, seen } = await mount(() => pending)
try { try {
@ -188,7 +192,8 @@ describe("useEvent", () => {
expect(sdk.connection.status()).toBe("connected") expect(sdk.connection.status()).toBe("connected")
complete(replacement) complete(replacement)
await reload await reload
expect(sdk.client).toBe(replacement) expect(sdk.client).toBe(replacement.client)
expect(sdk.api).toBe(replacement.api)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }

View file

@ -1,3 +1,4 @@
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { V2Event } from "@opencode-ai/sdk/v2" import type { V2Event } from "@opencode-ai/sdk/v2"
@ -117,3 +118,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
export function createClient(fetch: typeof globalThis.fetch) { export function createClient(fetch: typeof globalThis.fetch) {
return createOpencodeClient({ baseUrl: "http://test", fetch }) return createOpencodeClient({ baseUrl: "http://test", fetch })
} }
export function createApi(fetch: typeof globalThis.fetch) {
return OpenCode.make({ baseUrl: "http://test", fetch })
}