diff --git a/bun.lock b/bun.lock index 87361c62e8b..48c6ce9af38 100644 --- a/bun.lock +++ b/bun.lock @@ -98,6 +98,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", @@ -581,6 +582,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -932,6 +934,7 @@ "name": "@opencode-ai/tui", "version": "1.17.11", "dependencies": { + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2ca7a5ad668..52a74be2517 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 32a225265cc..053b99f433a 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -3,6 +3,7 @@ import { TuiConfig } from "@opencode-ai/tui/config" import { Effect } from "effect" import { Global } from "@opencode-ai/core/global" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" +import { OpenCode } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" type Transport = { url: string; headers: RequestInit["headers"] } @@ -12,23 +13,23 @@ export function runTui(transport: Transport, reload?: () => Promise) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { const options = { baseUrl: transport.url, headers: transport.headers } - const client = createOpencodeClient(options) - const directory = yield* Effect.tryPromise(() => - client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }), - ).pipe( - Effect.map((response) => response.data.location.directory), + const api = OpenCode.make(options) + const directory = yield* Effect.tryPromise(() => api.files.list({ location: { directory: process.cwd() } })).pipe( + Effect.map((response) => response.location.directory), Effect.catch(() => - Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe( - Effect.map((response) => response.data.directory), - ), + Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), ), ) return yield* run({ client: createOpencodeClient({ ...options, directory }), + api, reload: reload ? async () => { 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, args: {}, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6955d7d8c58..82b671ea39f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,2 +1,3 @@ export * from "./generated/index" export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types" +export type OpenCodeClient = ReturnType diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fba8d9b5bee..6b6b98bf88a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -84,6 +84,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 288bc38ec6b..09f227c2592 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -3,6 +3,7 @@ import { UI } from "@/cli/ui" import { errorMessage } from "@opencode-ai/tui/util/error" import { validateSession } from "../tui/validate-session" import { ServerAuth } from "@/server/auth" +import { OpenCode } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2" export const AttachCommand = cmd({ @@ -134,6 +135,7 @@ export const AttachCommand = cmd({ await Effect.runPromise( run({ client: createOpencodeClient({ baseUrl: args.url, headers, directory }), + api: OpenCode.make({ baseUrl: args.url, headers }), config, pluginHost: createLegacyTuiPluginHost(), args: { diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 8063656e68d..a661b40396d 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -8,6 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error" import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { Filesystem } from "@/util/filesystem" +import { OpenCode } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { writeHeapSnapshot } from "v8" import { validateSession } from "../tui/validate-session" @@ -205,6 +206,7 @@ export const TuiThreadCommand = cmd({ await Effect.runPromise( run({ client: createOpencodeClient({ baseUrl: url, directory: cwd }), + api: OpenCode.make({ baseUrl: url }), async onSnapshot() { const tui = writeHeapSnapshot("tui.heapsnapshot") const server = await client.call("snapshot", undefined) diff --git a/packages/tui/package.json b/packages/tui/package.json index 1595957190a..ba2621716de 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -47,6 +47,7 @@ "./component/spinner": "./src/component/spinner.tsx" }, "dependencies": { + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 48e609a87f9..820a069e839 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -76,6 +76,7 @@ import { useOpencodeKeymap, } from "./keymap" +import type { OpenCodeClient } from "@opencode-ai/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" @@ -135,7 +136,8 @@ const appBindingCommands = [ export type TuiInput = { client: OpencodeClient - reload?: () => Promise + api: OpenCodeClient + reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise @@ -290,7 +292,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 0939fedcda7..20beb09ded5 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1,5 +1,6 @@ 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 { useClipboard } from "../context/clipboard" import { useData } from "../context/data" @@ -22,6 +23,7 @@ const INTEGRATION_PRIORITY: Record = { } type ConnectMethod = Exclude +type IntegrationAttempt = IntegrationsConnectOauthOutput["data"] export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -109,11 +111,8 @@ function manageConnections( title: `Disconnect ${connection.label}`, value: connection.id, onSelect: () => { - void sdk.client.v2.credential - .remove( - { credentialID: connection.id, location: location(data) }, - { throwOnError: true }, - ) + void sdk.api.credentials + .remove({ credentialID: connection.id, location: location(data) }) .then(() => disconnected(integration.name, data, dialog, toast)) .catch(toast.error) }, @@ -124,11 +123,7 @@ function manageConnections( }) } -function selectMethod( - integration: IntegrationInfo, - methods: ConnectMethod[], - dialog: ReturnType, -) { +function selectMethod(integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType) { if (methods.length === 1) return openMethod(integration, methods[0], dialog) dialog.replace(() => ( , -) { +function openMethod(integration: IntegrationInfo, method: ConnectMethod, dialog: ReturnType) { if (method.type === "key") { dialog.replace(() => ) return @@ -168,21 +159,16 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract { if (!key) return - void sdk.client.v2.integration.connect - .key( - { - integrationID: props.integration.id, - location: location(data), - key, - }, - { throwOnError: true }, - ) + void sdk.api.integrations + .connectKey({ + integrationID: props.integration.id, + location: location(data), + key, + }) .then(() => connected(props.integration.name, data, dialog, toast)) .catch((cause) => setError(message(cause))) }} - description={() => ( - {(value) => {value()}} - )} + description={() => {(value) => {value()}}} /> ) } @@ -208,25 +194,22 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void sdk.client.v2.integration.connect - .oauth( - { - integrationID: props.integration.id, - location: location(data), - methodID: props.method.id, - inputs: props.inputs, - }, - { throwOnError: true }, - ) + void sdk.api.integrations + .connectOauth({ + integrationID: props.integration.id, + location: location(data), + methodID: props.method.id, + inputs: props.inputs, + }) .then((result) => { - if (result.data.data.mode === "code") { + if (result.data.mode === "code") { dialog.replace(() => ( - + )) return } dialog.replace(() => ( - + )) }) .catch((cause) => { @@ -265,10 +248,10 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt })) const poll = () => { - void sdk.client.v2.integration.attempt - .status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true }) + void sdk.api.integrations + .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { - const status = result.data.data + const status = result.data if (status.status === "pending") { timer = setTimeout(poll, 500) return @@ -292,7 +275,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt onCleanup(() => { if (timer) clearTimeout(timer) 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 ( @@ -317,7 +300,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt onCleanup(() => { 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 ( @@ -326,11 +309,8 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void sdk.client.v2.integration.attempt - .complete( - { attemptID: props.attempt.attemptID, location: location(data), code }, - { throwOnError: true }, - ) + void sdk.api.integrations + .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration.name, data, dialog, toast) @@ -348,13 +328,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt ) } -function OAuthView(props: { - title: string - url?: string - instructions?: string - message: string - copy?: boolean -}) { +function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) { const dialog = useDialog() const { theme } = useTheme() return ( @@ -371,7 +345,9 @@ function OAuthView(props: { {(url) => ( - {(instructions) => {instructions()}} + + {(instructions) => {instructions()}} + )} @@ -436,7 +412,11 @@ async function connected( dialog: ReturnType, toast: ReturnType, ) { - 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}` }) dialog.clear() } @@ -447,7 +427,11 @@ async function disconnected( dialog: ReturnType, toast: ReturnType, ) { - 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}` }) dialog.clear() } diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index b4fb2f2b452..200c18d97af 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -11,6 +11,7 @@ import { abbreviateHome } from "../runtime" import { useTuiPaths } from "../context/runtime" import { Locale } from "../util/locale" import { errorMessage } from "../util/error" +import { isRecord } from "../util/record" import { useToast } from "../ui/toast" import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" @@ -74,10 +75,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { () => (props.initialRemoving ? undefined : props.projectID), async (projectID, info): Promise => { try { - await sdk.client.v2.projectCopy.refresh( - { projectID, location: { directory: projectContext.instance.directory() || paths.cwd } }, - { throwOnError: true }, - ) + await sdk.api.projectCopies.refresh({ + projectID, + location: { directory: projectContext.instance.directory() || paths.cwd }, + }) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) setLoadError(undefined) return directories.data ?? [] @@ -221,18 +222,21 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { setToDelete(undefined) setRemoving(selected.directory) setWorking(true) - const result = await sdk.client.v2.projectCopy + const error = await sdk.api.projectCopies .remove({ projectID: props.projectID, location: { directory: projectContext.instance.directory() || paths.cwd }, directory: selected.directory, force: false, }) - .catch((error) => ({ error })) - if (result.error) { + .then( + () => undefined, + (error) => error, + ) + if (error) { setRemoving(undefined) 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 choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], { title: "Delete working copy?", @@ -243,19 +247,22 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { return } reopen(selected.directory) - const forced = await sdk.client.v2.projectCopy + const forcedError = await sdk.api.projectCopies .remove({ projectID: props.projectID, location: { directory: projectContext.instance.directory() || paths.cwd }, directory: selected.directory, force: true, }) - .catch((error) => ({ error })) - if (forced.error) { + .then( + () => undefined, + (error) => error, + ) + if (forcedError) { toast.show({ variant: "error", title: "Failed to delete project copy", - message: errorMessage(forced.error), + message: errorMessage(forcedError), }) reopen() return @@ -269,7 +276,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { toast.show({ variant: "error", title: "Failed to delete project copy", - message: errorMessage(result.error), + message: errorMessage(error), }) return } diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 771ab0eca56..fc1915e3988 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -31,11 +31,16 @@ export function DialogSessionList() { const [searchResults] = createResource(search, async (query) => { if (!query) return - const response = await sdk.client.v2.session.list( - { search: query, limit: 50, order: "desc" }, - { throwOnError: true }, - ) - return { query, sessions: response.data.data } + const location = data.location.default() + const response = await sdk.api.sessions.list({ + search: query, + limit: 50, + 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)) @@ -59,7 +64,11 @@ export function DialogSessionList() { const options = createMemo(() => { 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 pinnedSet = new Set(pinned) const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1])) diff --git a/packages/tui/src/component/dialog-session-rename.tsx b/packages/tui/src/component/dialog-session-rename.tsx index 72dfb8b11d6..1b9e3372cdf 100644 --- a/packages/tui/src/component/dialog-session-rename.tsx +++ b/packages/tui/src/component/dialog-session-rename.tsx @@ -17,11 +17,15 @@ export function DialogSessionRename(props: { sessionID: string; currentTitle?: s onConfirm={(value) => { const title = value.trim() if (!title) return - void sdk.client.v2.session - .rename({ sessionID: props.sessionID, title }, { throwOnError: true }) + void sdk.api.sessions + .rename({ sessionID: props.sessionID, title }) .then(() => dialog.clear()) .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()} diff --git a/packages/tui/src/component/dialog-tag.tsx b/packages/tui/src/component/dialog-tag.tsx index 39560d5eacf..2b3dc83524b 100644 --- a/packages/tui/src/component/dialog-tag.tsx +++ b/packages/tui/src/component/dialog-tag.tsx @@ -17,13 +17,15 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) { const [files] = createResource( () => [store.filter], async () => { - const result = await sdk.client.find.files({ - query: store.filter, - workspace: project.workspace.current(), - }) - if (result.error) return [] - const sliced = (result.data ?? []).slice(0, 5) - return sliced + const result = await sdk.api.files + .find({ + query: store.filter, + type: "file", + limit: 5, + location: { workspace: project.workspace.current() }, + }) + .catch(() => undefined) + return result?.data.map((item) => item.path) ?? [] }, ) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 28ebef05dc5..38e387e499d 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -320,29 +320,26 @@ export function Autocomplete(props: { if (referenceMatch()) return [] const { lineRange, baseQuery } = extractLineRange(input.query ?? "") - // Get files from SDK - const result = await sdk.client.v2.fs.find({ - query: baseQuery, - limit: "20", - location: { - directory: input.location?.directory, - workspace: input.location?.workspaceID ?? project.workspace.current(), - }, - }) + const result = await sdk.api.files + .find({ + query: baseQuery, + limit: 20, + location: { + directory: input.location?.directory, + workspace: input.location?.workspaceID ?? project.workspace.current(), + }, + }) + .catch(() => undefined) const options: AutocompleteOption[] = [] // Add file options. Trust the order returned by fff (frecency, fuzzy // score, filename bonus, etc. are already factored in). - if (!result.error && result.data) { + if (result) { const width = props.anchor().width - 4 options.push( - ...result.data.data.map((item): AutocompleteOption => { - const { filename, part } = createFilePart( - item, - path.join(result.data.location.directory, item.path), - lineRange, - ) + ...result.data.map((item): AutocompleteOption => { + const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) return { display: Locale.truncateMiddle(filename, width), value: filename, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index f1082fef5e4..f868f336792 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" 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 { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -158,11 +158,12 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => data.session.status(props.sessionID ?? "")) - const activeSubagents = createMemo(() => - data.session - .list() - .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running") - .length, + const activeSubagents = createMemo( + () => + data.session + .list() + .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running") + .length, ) const runningShells = createMemo( () => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length, @@ -424,7 +425,7 @@ export function Prompt(props: PromptProps) { }, 5000) if (store.interrupt >= 2) { - void sdk.client.v2.session.interrupt({ + void sdk.api.sessions.interrupt({ sessionID: props.sessionID, }) setStore("interrupt", 0) @@ -1009,18 +1010,23 @@ export function Prompt(props: PromptProps) { const directory = await move.getDirectory(store.prompt.input) if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) + const location = data.location.default() - const res = await sdk.client.v2.session.create({ - location: directory ? { directory, workspaceID } : undefined, - agent: agent.id, - model: { - providerID: selectedModel.providerID, - id: selectedModel.modelID, - variant, - }, - }) + const created = await sdk.api.sessions + .create({ + location: directory + ? { directory, workspaceID } + : { directory: location.directory, workspaceID: workspaceID ?? location.workspaceID }, + agent: agent.id, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + }) + .catch(() => undefined) - if (res.error) { + if (!created) { if (finishMoveProgress) move.finishSubmit() toast.show({ message: "Creating a session failed. Open console for more details.", @@ -1030,8 +1036,9 @@ export function Prompt(props: PromptProps) { return true } - sessionID = res.data.data.id - session = res.data.data + sessionID = created.id + // 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( @@ -1107,65 +1114,70 @@ export function Prompt(props: PromptProps) { session = data.session.get(sessionID) } 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 ( session?.model?.providerID !== selectedModel.providerID || session.model.id !== selectedModel.modelID || session.model.variant !== variant ) { - await sdk.client.v2.session.switchModel( - { - sessionID, - model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, - }, - { throwOnError: true }, - ) + await sdk.api.sessions.switchModel({ + sessionID, + model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, + }) } if (session?.revert) { - const revertResult = await sdk.client.v2.session.revert.commit({ sessionID }) - if (revertResult.error) { - toast.show({ title: "Failed to commit revert", message: errorMessage(revertResult.error), variant: "error" }) + const error = await sdk.api.sessions.commit({ sessionID }).then( + () => undefined, + (error) => error, + ) + if (error) { + toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" }) return false } } - const result = await sdk.client.v2.session.prompt({ - sessionID, - prompt: { - text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), - files: nonTextParts.flatMap((part) => - part.type === "file" - ? [ - { - uri: part.url, - name: part.filename, - source: part.source - ? { - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - } - : undefined, - }, - ] - : [], - ), - agents: nonTextParts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - source: part.source - ? { 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" }) + const error = await sdk.api.sessions + .prompt({ + sessionID, + prompt: { + text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), + files: nonTextParts.flatMap((part) => + part.type === "file" + ? [ + { + uri: part.url, + name: part.filename, + source: part.source + ? { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } + : undefined, + }, + ] + : [], + ), + agents: nonTextParts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + source: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ), + }, + }) + .then( + () => undefined, + (error) => error, + ) + if (error) { + toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) return false } if (editorParts.length > 0) editor.markSelectionSent() diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 40956a148eb..b567fd97f49 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -37,17 +37,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess { projectID, context }, { throwOnError: true }, ) - const result = await sdk.client.v2.projectCopy.create( - { - projectID, - location: { directory: project.instance.directory() || paths.cwd }, - strategy: "git_worktree", - directory: path.join(paths.worktree, projectID.slice(0, 6)), - name: generated.data.name, - }, - { throwOnError: true }, - ) - const directory = result.data?.directory + const result = await sdk.api.projectCopies.create({ + projectID, + location: { directory: project.instance.directory() || paths.cwd }, + strategy: "git_worktree", + directory: path.join(paths.worktree, projectID.slice(0, 6)), + name: generated.data.name, + }) + const directory = result.directory if (!directory) throw new Error("No project copy directory returned") // Call a location-based route to make sure it's bootstrapped diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index df3220f5012..32a673f5063 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -61,6 +61,14 @@ function locationQuery(ref?: LocationRef) { return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined } +type Mutable = + T extends ReadonlyArray ? Mutable[] : T extends object ? { -readonly [K in keyof T]: Mutable } : T + +function mutable(value: T): Mutable { + // 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 +} + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: () => { @@ -288,13 +296,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.text.delta": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID) + const match = message.latestText( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.textID, + ) if (match) match.text += event.data.delta }) break case "session.next.text.ended": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID) + const match = message.latestText( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.textID, + ) if (match) match.text = event.data.text }) break @@ -311,19 +325,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.input.delta": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (match?.state.status === "pending") match.state.input += event.data.delta }) break case "session.next.tool.input.ended": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (match?.state.status === "pending") match.state.input = event.data.text }) break case "session.next.tool.called": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (!match) return match.time.ran = event.data.timestamp match.provider = event.data.provider @@ -332,7 +355,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.progress": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (match?.state.status !== "running") return match.state.structured = event.data.structured match.state.content = [...event.data.content] @@ -340,7 +366,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.success": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (match?.state.status !== "running") return match.state = { status: "completed", @@ -359,7 +388,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.failed": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return match.state = { status: "error", @@ -522,8 +554,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.status[sessionID] ?? "idle" }, async refresh(sessionID: string) { - const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true }) - setStore("session", "info", sessionID, result.data.data) + setStore("session", "info", sessionID, mutable(await sdk.api.sessions.get({ sessionID }))) }, message: { ids(sessionID: string) { @@ -540,11 +571,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async refresh(sessionID: string) { setStore("session", "message", sessionID, []) messageIndex.set(sessionID, new Map()) - const response = await sdk.client.v2.session.messages( - { sessionID, limit: 200, order: "desc" }, - { throwOnError: true }, - ) - const loaded = response.data.data.toReversed() + const loaded = mutable( + (await sdk.api.messages.list({ sessionID, limit: 200, order: "desc" })).data, + ).toReversed() const live = store.session.message[sessionID] ?? [] const liveByID = new Map(live.map((message) => [message.id, message])) const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live] @@ -559,8 +588,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.permission[sessionID] }, async refresh(sessionID: string) { - const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true }) - setStore("session", "permission", sessionID, result.data.data) + setStore("session", "permission", sessionID, mutable(await sdk.api.permissions.list({ sessionID }))) }, }, question: { @@ -568,8 +596,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.question[sessionID] }, async refresh(sessionID: string) { - const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true }) - setStore("session", "question", sessionID, result.data.data) + setStore("session", "question", sessionID, mutable(await sdk.api.questions.list({ sessionID }))) }, }, }, @@ -579,8 +606,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.project.permission[projectID] }, async refresh(projectID: string) { - const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true }) - setStore("project", "permission", projectID, result.data.data) + setStore("project", "permission", projectID, mutable(await sdk.api.permissions.listSaved({ projectID }))) }, }, }, @@ -606,8 +632,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return defaultLocation() }, async refresh(ref?: LocationRef) { - const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true }) - const location = response.data + const location = await sdk.api.location.get({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(location) if (!store.location[key]) setStore("location", key, {}) if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) @@ -617,9 +642,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.agent }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], agent: result.data.data }) + const result = await sdk.api.agents.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], agent: mutable(result.data) }) }, }, command: { @@ -627,9 +652,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.command }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], command: result.data.data }) + const result = await sdk.api.commands.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], command: mutable(result.data) }) }, }, integration: { @@ -637,12 +662,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.integration }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.integration.list( - { location: locationQuery(ref) }, - { throwOnError: true }, - ) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], integration: result.data.data }) + const result = await sdk.api.integrations.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], integration: mutable(result.data) }) }, }, model: { @@ -650,9 +672,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.model }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], model: result.data.data }) + const result = await sdk.api.models.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], model: mutable(result.data) }) }, }, provider: { @@ -660,9 +682,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.provider }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], provider: result.data.data }) + const result = await sdk.api.providers.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], provider: mutable(result.data) }) }, }, reference: { @@ -670,9 +692,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.reference }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], reference: result.data.data }) + const result = await sdk.api.references.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], reference: mutable(result.data) }) }, }, skill: { @@ -680,9 +702,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.skill }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], skill: result.data.data }) + const result = await sdk.api.skills.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], skill: mutable(result.data) }) }, }, }, @@ -690,24 +712,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async function bootstrap() { const settled = await Promise.allSettled([ - sdk.client.v2.session - .list({ limit: 50, order: "desc" }, { throwOnError: true }) + sdk.api.sessions + .list({ + limit: 50, + order: "desc", + directory: defaultLocation().directory, + workspace: defaultLocation().workspaceID, + }) .then((response) => setStore( "session", "info", produce((draft) => { - for (const session of response.data.data) draft[session.id] = session + for (const session of response.data) draft[session.id] = mutable(session) }), ), ), - sdk.client.v2.session.active({ throwOnError: true }).then((response) => - setStore( - "session", - "status", - Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])), + sdk.api.sessions + .active() + .then((active) => + setStore( + "session", + "status", + Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const])), + ), ), - ), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 751f57bd13a..6750419cfd3 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,3 +1,4 @@ +import type { OpenCodeClient } from "@opencode-ai/client" import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup, onMount } from "solid-js" @@ -11,9 +12,14 @@ const connectTimeout = 2_000 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", - init: (props: { client: OpencodeClient; reload?: () => Promise }) => { + init: (props: { + client: OpencodeClient + api: OpenCodeClient + reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + }) => { const abort = new AbortController() let client = props.client + let api = props.api const events = createGlobalEmitter() const [connection, setConnection] = createStore<{ status: SDKConnectionStatus @@ -40,7 +46,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ while (!abort.signal.aborted && !controller.signal.aborted) { const connection = new AbortController() const cancel = () => connection.abort(controller.signal.reason) - const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout) + const timeout = setTimeout( + () => connection.abort(new Error("Timed out connecting to server")), + connectTimeout, + ) controller.signal.addEventListener("abort", cancel, { once: true }) const error = await (async () => { const response = await current.v2.event.subscribe({ @@ -91,7 +100,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ pending = Promise.resolve() .then(props.reload) .then(async (next) => { - client = next + client = next.client + api = next.api if (!abort.signal.aborted) await start() }) .finally(() => { @@ -112,6 +122,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ get client() { return client }, + get api() { + return api + }, event: { on: events.on, listen: events.listen, diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index c7c1a7c3733..2fa53ef377b 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -11,9 +11,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set const clipboard = useClipboard() const toast = useToast() const sdk = useSDK() - const message = createMemo(() => - data.session.message.get(props.sessionID, props.messageID), - ) + const message = createMemo(() => data.session.message.get(props.sessionID, props.messageID)) return ( { - const result = await sdk.client.v2.session.revert.stage({ - sessionID: props.sessionID, - messageID: props.messageID, - }) - if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + await sdk.api.sessions + .stage({ sessionID: props.sessionID, messageID: props.messageID }) + .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() }, }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 8a3c8bffd88..3c521a6b1e9 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -156,10 +156,11 @@ export function Session() { const promptRef = usePromptRef() const session = createMemo(() => data.session.get(route.sessionID)) const messageIDs = createMemo(() => data.session.message.ids(route.sessionID)) - const sessionMessages = () => messageIDs().flatMap((id) => { - const message = data.session.message.get(route.sessionID, id) - return message ? [message] : [] - }) + const sessionMessages = () => + messageIDs().flatMap((id) => { + const message = data.session.message.get(route.sessionID, id) + return message ? [message] : [] + }) const location = createMemo(() => session()?.location) createEffect(() => { @@ -286,7 +287,10 @@ export function Session() { if (!message) return false 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) @@ -361,7 +365,7 @@ export function Session() { aliases: ["summarize"], }, run: () => { - void sdk.client.v2.session.compact({ sessionID: route.sessionID }) + void sdk.api.sessions.compact({ sessionID: route.sessionID }) dialog.clear() }, }, @@ -395,8 +399,11 @@ export function Session() { dialog.clear() return } - const result = await sdk.client.v2.session.revert.stage({ sessionID: route.sessionID, messageID: target }) - if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + const error = await sdk.api.sessions.stage({ sessionID: route.sessionID, messageID: target }).then( + () => undefined, + (error) => error, + ) + if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) dialog.clear() })() }, @@ -409,8 +416,11 @@ export function Session() { slash: { name: "redo" }, run: () => { void (async () => { - const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID }) - if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then( + () => undefined, + (error) => error, + ) + if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) dialog.clear() })() }, @@ -878,23 +888,21 @@ export function Session() { 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 ?? []} /> 0}> - + 0}> - + @@ -957,9 +965,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) = {(row) => ( - - {(message) => } - + {(message) => } )} @@ -1073,8 +1079,8 @@ function SessionGroupView(props: { result[name] = (result[name] ?? 0) + 1 return result }, {}) - const tools = Object.entries(counts).map(([name, count]) => - `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, + const tools = Object.entries(counts).map( + ([name, count]) => `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, ) return `${props.completed ? "Explored" : "Exploring"} — ${tools.join(", ")}` }) @@ -1116,9 +1122,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { const { theme } = useTheme() const model = createMemo( () => - ctx.models().find( - (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, - )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ctx + .models() + .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(() => props.message.time.completed ? props.message.time.completed - props.message.time.created : 0, @@ -1158,14 +1165,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) { function CompactionMessage() { const { theme } = useTheme() - return ( - - ) + return } function statusLabel(status: "added" | "modified" | "deleted") { @@ -1197,8 +1197,11 @@ function RevertMessage(props: { onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return void (async () => { - const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID }) - if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then( + () => undefined, + (error) => error, + ) + if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) })() }} flexShrink={0} @@ -1207,7 +1210,12 @@ function RevertMessage(props: { customBorderChars={SplitBorder.customBorderChars} borderColor={theme.backgroundPanel} > - + {props.count} message{props.count === 1 ? "" : "s"} reverted @@ -1251,54 +1259,63 @@ function UserMessage(props: { message: SessionMessageUser }) { return ( + { + setHover(true) + }} + onMouseOut={() => { + setHover(false) + }} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ) + }} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} + flexShrink={0} > - { - setHover(true) - }} - onMouseOut={() => { - setHover(false) - }} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ) - }} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} - flexShrink={0} - > - {props.message.text} - - - - {(file) => { - const directory = file.mime === "application/x-directory" - return ( - - - {directory ? " Directory " : " File "} - - {file.name ?? file.uri} - - ) - }} - - - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - + {props.message.text} + + + + {(file) => { + const directory = file.mime === "application/x-directory" + return ( + + + {directory ? " Directory " : " File "} + + + {" "} + {file.name ?? file.uri}{" "} + + + ) + }} + + + + + + {Locale.todayTimeOrDateTime(props.message.time.created)} + + + ) } @@ -1309,9 +1326,10 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole const { theme } = useTheme() const model = createMemo( () => - ctx.models().find( - (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, - )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ctx + .models() + .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(() => { @@ -1325,10 +1343,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole }) const exploration = createMemo(() => { - const grouped = new Map< - string, - { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean } - >() + const grouped = new Map() if (!ctx.groupExploration()) return grouped const runs = props.message.content .map((part) => @@ -1471,7 +1486,9 @@ function ReasoningPart(props: { // OpenRouter encrypts some reasoning blocks; drop the placeholder. 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 duration = createMemo(() => { const end = props.part.time?.completed ?? props.message.time.completed @@ -1488,11 +1505,7 @@ function ReasoningPart(props: { return ( - + void }) { return ( - + @@ -2003,12 +2011,7 @@ function Write(props: ToolProps) { - + Write {pathFormatter.format(stringValue(props.input.path))} @@ -2473,7 +2476,14 @@ export function parseApplyPatchFiles(value: unknown) { const patch = stringValue(file.patch) const additions = numberValue(file.additions) 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 [{ type, relativePath, filePath, patch, additions, deletions, movePath: stringValue(file.movePath) }] }) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 91e2189a188..35230f11608 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -186,7 +186,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director onSelect={(option) => { setStore("stage", "permission") if (option === "cancel") return - void sdk.client.v2.session.permission.reply({ + void sdk.api.permissions.reply({ sessionID: props.request.sessionID, reply: "always", requestID: props.request.id, @@ -197,7 +197,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director { - void sdk.client.v2.session.permission.reply({ + void sdk.api.permissions.reply({ sessionID: props.request.sessionID, reply: "reject", requestID: props.request.id, @@ -443,14 +443,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director setStore("stage", "reject") return } - void sdk.client.v2.session.permission.reply({ + void sdk.api.permissions.reply({ sessionID: props.request.sessionID, reply: "reject", requestID: props.request.id, }) return } - void sdk.client.v2.session.permission.reply({ + void sdk.api.permissions.reply({ sessionID: props.request.sessionID, reply: "once", requestID: props.request.id, diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index ded2c6db4f4..9bbabaaaeed 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -47,15 +47,15 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) - void sdk.client.v2.session.question.reply({ + void sdk.api.questions.reply({ sessionID: props.request.sessionID, requestID: props.request.id, - questionV2Reply: { answers }, + answers, }) } function reject() { - void sdk.client.v2.session.question.reject({ + void sdk.api.questions.reject({ sessionID: props.request.sessionID, requestID: props.request.id, }) @@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: setStore("custom", inputs) } if (single()) { - void sdk.client.v2.session.question.reply({ + void sdk.api.questions.reply({ sessionID: props.request.sessionID, requestID: props.request.id, - questionV2Reply: { answers: [[answer]] }, + answers: [[answer]], }) return } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 019cf06991f..9ee2742894a 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -4,7 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing" import { Effect } from "effect" import { Global } from "@opencode-ai/core/global" 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 () => { 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( run({ client: createClient(calls.fetch), + api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: {}, pluginHost: { @@ -61,26 +62,23 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) const events = createEventStream() - const calls = createFetch( - (url) => { - if (url.pathname === "/api/session") - return json({ - data: [ - { - id: "dummy", - title: "Demo session", - projectID: "project", - location: { directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - }, - ], - cursor: {}, - }) - }, - events, - ) + const calls = createFetch((url) => { + if (url.pathname === "/api/session") + return json({ + data: [ + { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }, + ], + cursor: {}, + }) + }, events) const originalWrite = process.stdout.write.bind(process.stdout) let stdout = "" let api: TuiPluginApi | undefined @@ -99,6 +97,7 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { const task = Effect.runPromise( run({ client: createClient(calls.fetch), + api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: { continue: true }, pluginHost: { diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index f658939bb98..d340c8558ec 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -7,7 +7,7 @@ import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" import { SyncProvider, useSync } from "../../../../src/context/sync" 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" export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk" @@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) { - + {}}> diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 060f3de1a26..50f5306c217 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -6,7 +6,7 @@ import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" 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" async function wait(fn: () => boolean, timeout = 2000) { @@ -69,7 +69,7 @@ test("refreshes resources into reactive getters", async () => { const app = await testRender(() => ( - + @@ -139,7 +139,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { const app = await testRender(() => ( - + @@ -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 () => { const events = createEventStream() const calls = createFetch((url) => { - if (url.pathname === "/api/session/active") - return json({ data: { "session-active": { type: "running" } } }) + if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } }) }, events) let data!: ReturnType @@ -184,7 +183,7 @@ test("tracks session status from active sessions and execution events", async () const app = await testRender(() => ( - + @@ -296,7 +295,7 @@ test("refreshes integrations after integration updates", async () => { const app = await testRender(() => ( - + @@ -337,7 +336,7 @@ test("refreshes effective catalog data after catalog updates", async () => { const app = await testRender(() => ( - + @@ -382,7 +381,7 @@ test("refreshes references after updates", async () => { const app = await testRender(() => ( - + @@ -415,7 +414,7 @@ test("adds and dismisses permission requests from live events", async () => { const app = await testRender(() => ( - + @@ -480,7 +479,7 @@ test("adds and dismisses question requests from live events", async () => { const app = await testRender(() => ( - + @@ -548,7 +547,7 @@ test("settles pending tools when a live failure arrives", async () => { const app = await testRender(() => ( - + @@ -677,7 +676,7 @@ test("renders admitted prompts only after they become model-visible", async () = const app = await testRender(() => ( - + @@ -750,7 +749,7 @@ test("projects live context updates with their message ID", async () => { const app = await testRender(() => ( - + diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 108bf940c98..ccf5be2fcba 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -1,12 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" +import type { OpenCodeClient } from "@opencode-ai/client" import { testRender } from "@opentui/solid" import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2" import { onMount } from "solid-js" import { ProjectProvider, useProject } from "../../../src/context/project" import { SDKProvider, useSDK } from "../../../src/context/sdk" 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" const projectID = "proj_test" @@ -46,7 +47,7 @@ function update(version: string): V2Event { } } -async function mount(reload?: () => Promise) { +async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) { const events = createEventStream() const calls = createFetch(undefined, events) const seen: V2Event[] = [] @@ -60,7 +61,7 @@ async function mount(reload?: () => Promise) { const app = await testRender(() => ( - + { @@ -150,7 +151,8 @@ describe("useEvent", () => { test("reloads the host and reconnects the event stream", async () => { let calls = 0 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 () => { calls += 1 return replacement @@ -164,19 +166,21 @@ describe("useEvent", () => { await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded")) expect(calls).toBe(1) - expect(sdk.client).toBe(replacement) + expect(sdk.client).toBe(replacement.client) + expect(sdk.api).toBe(replacement.api) } finally { app.renderer.destroy() } }) test("keeps the current event stream alive while the host reload is pending", async () => { - let complete!: (client: OpencodeClient) => void - const pending = new Promise((resolve) => { + let complete!: (client: { client: OpencodeClient; api: OpenCodeClient }) => void + const pending = new Promise<{ client: OpencodeClient; api: OpenCodeClient }>((resolve) => { complete = resolve }) 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) try { @@ -188,7 +192,8 @@ describe("useEvent", () => { expect(sdk.connection.status()).toBe("connected") complete(replacement) await reload - expect(sdk.client).toBe(replacement) + expect(sdk.client).toBe(replacement.client) + expect(sdk.api).toBe(replacement.api) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d7f6cfb1a5a..8cb704d626b 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -1,3 +1,4 @@ +import { OpenCode } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { V2Event } from "@opencode-ai/sdk/v2" @@ -117,3 +118,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType