diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 3ca24892d1..3dcf805646 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -461,8 +461,10 @@ import type { V2VcsDiffResponses, V2VcsStatusErrors, V2VcsStatusResponses, - V2WebsearchProviderGetErrors, - V2WebsearchProviderGetResponses, + V2WebsearchProviderListErrors, + V2WebsearchProviderListResponses, + V2WebsearchProviderSelectedErrors, + V2WebsearchProviderSelectedResponses, V2WebsearchProviderSelectErrors, V2WebsearchProviderSelectResponses, V2WebsearchQueryErrors, @@ -8254,11 +8256,11 @@ export class Debug extends HeyApiClient { export class Provider3 extends HeyApiClient { /** - * Get default web search provider + * List web search providers * - * Return the globally selected web search provider. + * Return the registered web search providers. */ - public get( + public list( parameters?: { location?: { directory?: string | null @@ -8269,8 +8271,8 @@ export class Provider3 extends HeyApiClient { ) { const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) return (options?.client ?? this.client).get< - V2WebsearchProviderGetResponses, - V2WebsearchProviderGetErrors, + V2WebsearchProviderListResponses, + V2WebsearchProviderListErrors, ThrowOnError >({ url: "/api/websearch/provider", @@ -8279,6 +8281,32 @@ export class Provider3 extends HeyApiClient { }) } + /** + * Get selected web search provider + * + * Return the globally selected web search provider. + */ + public selected( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2WebsearchProviderSelectedResponses, + V2WebsearchProviderSelectedErrors, + ThrowOnError + >({ + url: "/api/websearch/provider/selected", + ...options, + ...params, + }) + } + /** * Select default web search provider * @@ -8310,7 +8338,7 @@ export class Provider3 extends HeyApiClient { V2WebsearchProviderSelectErrors, ThrowOnError >({ - url: "/api/websearch/provider", + url: "/api/websearch/provider/selected", ...options, ...params, headers: { @@ -8326,7 +8354,7 @@ export class Websearch extends HeyApiClient { /** * Search the web * - * Run one web search through the selected integration. Specify a provider to override the configured default. + * Run one web search through the selected provider. Specify a provider to override the configured default. */ public query( parameters?: { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8b5b5c749f..e30a6e46c3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -87,6 +87,7 @@ export type Event = | EventFormCreated | EventFormReplied | EventFormCancelled + | EventWebsearchUpdated | EventLspUpdated | EventPermissionAsked | EventPermissionReplied @@ -1408,7 +1409,7 @@ export type GlobalEvent = { id: string type: "form.created" properties: { - form: FormFormInfo | FormUrlInfo | FormIntegrationInfo + form: FormFormInfo | FormUrlInfo } } | { @@ -1428,6 +1429,13 @@ export type GlobalEvent = { sessionID: string } } + | { + id: string + type: "websearch.updated" + properties: { + [key: string]: unknown + } + } | { id: string type: "lsp.updated" @@ -3084,6 +3092,7 @@ export type V2Event = | FormCreated | FormReplied | FormCancelled + | WebsearchUpdated | LspUpdated | PermissionAsked | PermissionReplied @@ -3563,15 +3572,6 @@ export type FormUrlInfo = { url: string } -export type FormIntegrationInfo = { - id: string - sessionID: string - title: string - metadata?: FormMetadata - mode: "integration" - integrationID: string -} - export type FormValue = | string | number @@ -5723,10 +5723,6 @@ export type IntegrationEnvMethod = { names: Array } -export type IntegrationWebSearch = { - connection: "optional" | "required" -} - export type ConnectionCredentialInfo = { type: "credential" id: string @@ -5744,7 +5740,6 @@ export type IntegrationInfo = { id: string name: string methods: Array - websearch?: IntegrationWebSearch connections: Array } @@ -6573,7 +6568,7 @@ export type FormCreated = { type: "form.created" location?: LocationRef data: { - form: FormFormInfo | FormUrlInfo | FormIntegrationInfo + form: FormFormInfo | FormUrlInfo } } @@ -6608,6 +6603,19 @@ export type FormCancelled = { } } +export type WebsearchUpdated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "websearch.updated" + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type LspUpdated = { id: string created: number @@ -7023,6 +7031,11 @@ export type ProjectCopyCopy = { export type VcsMode = "working" | "branch" +export type WebSearchProvider = { + id: string + name: string +} + export type WebSearchResult = { providerID: string text: string @@ -7851,7 +7864,7 @@ export type EventFormCreated = { id: string type: "form.created" properties: { - form: FormFormInfo | FormUrlInfo | FormIntegrationInfo + form: FormFormInfo | FormUrlInfo } } @@ -7874,6 +7887,14 @@ export type EventFormCancelled = { } } +export type EventWebsearchUpdated = { + id: string + type: "websearch.updated" + properties: { + [key: string]: unknown + } +} + export type EventLspUpdated = { id: string type: "lsp.updated" @@ -8701,6 +8722,7 @@ export type V2EventV2 = | FormCreatedV2 | FormRepliedV2 | FormCancelledV2 + | WebsearchUpdatedV2 | SessionStatusV22 | SessionIdleV2 | TuiPromptAppendV2 @@ -9930,15 +9952,6 @@ export type FormUrlInfoV2 = { url: string } -export type FormIntegrationInfoV2 = { - id: string - sessionID: string - title: string - metadata?: FormMetadata - mode: "integration" - integrationID: string -} - export type FormCreatePayloadV2 = { id?: string | null title: string @@ -10674,15 +10687,6 @@ export type FormUrlInfo1 = { url: string } -export type FormIntegrationInfo1 = { - id: string - sessionID: string - title: string - metadata?: FormMetadata1 - mode: "integration" - integrationID: string -} - export type FormCreatedV2 = { id: string created: number @@ -10692,7 +10696,7 @@ export type FormCreatedV2 = { type: "form.created" location?: LocationRefV2 data: { - form: FormFormInfo1 | FormUrlInfo1 | FormIntegrationInfo1 + form: FormFormInfo1 | FormUrlInfo1 } } @@ -10729,6 +10733,21 @@ export type FormCancelledV2 = { } } +export type WebsearchUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "websearch.updated" + location?: LocationRefV2 + data: + | { + [key: string]: unknown + } + | Array +} + export type SessionIdleV2 = { id: string created: number @@ -17245,7 +17264,7 @@ export type V2FormRequestListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -17282,7 +17301,7 @@ export type V2SessionFormListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -17323,7 +17342,7 @@ export type V2SessionFormCreateResponses = { * Success */ 200: { - data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2 + data: FormFormInfoV2 | FormUrlInfoV2 } } @@ -17361,7 +17380,7 @@ export type V2SessionFormGetResponses = { * Success */ 200: { - data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2 + data: FormFormInfoV2 | FormUrlInfoV2 } } @@ -18968,7 +18987,7 @@ export type V2DebugLocationListResponses = { export type V2DebugLocationListResponse = V2DebugLocationListResponses[keyof V2DebugLocationListResponses] -export type V2WebsearchProviderGetData = { +export type V2WebsearchProviderListData = { body?: never path?: never query?: { @@ -18980,7 +18999,48 @@ export type V2WebsearchProviderGetData = { url: "/api/websearch/provider" } -export type V2WebsearchProviderGetErrors = { +export type V2WebsearchProviderListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableErrorV2 +} + +export type V2WebsearchProviderListError = V2WebsearchProviderListErrors[keyof V2WebsearchProviderListErrors] + +export type V2WebsearchProviderListResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: Array + } +} + +export type V2WebsearchProviderListResponse = V2WebsearchProviderListResponses[keyof V2WebsearchProviderListResponses] + +export type V2WebsearchProviderSelectedData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/websearch/provider/selected" +} + +export type V2WebsearchProviderSelectedErrors = { /** * InvalidRequestError */ @@ -18991,9 +19051,10 @@ export type V2WebsearchProviderGetErrors = { 401: UnauthorizedError } -export type V2WebsearchProviderGetError = V2WebsearchProviderGetErrors[keyof V2WebsearchProviderGetErrors] +export type V2WebsearchProviderSelectedError = + V2WebsearchProviderSelectedErrors[keyof V2WebsearchProviderSelectedErrors] -export type V2WebsearchProviderGetResponses = { +export type V2WebsearchProviderSelectedResponses = { /** * Success */ @@ -19003,7 +19064,8 @@ export type V2WebsearchProviderGetResponses = { } } -export type V2WebsearchProviderGetResponse = V2WebsearchProviderGetResponses[keyof V2WebsearchProviderGetResponses] +export type V2WebsearchProviderSelectedResponse = + V2WebsearchProviderSelectedResponses[keyof V2WebsearchProviderSelectedResponses] export type V2WebsearchProviderSelectData = { body: { @@ -19016,7 +19078,7 @@ export type V2WebsearchProviderSelectData = { workspace?: string | null } | null } - url: "/api/websearch/provider" + url: "/api/websearch/provider/selected" } export type V2WebsearchProviderSelectErrors = { diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 51ba469c0e..a366eef263 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -25,6 +25,7 @@ const INTEGRATION_PRIORITY: Record = { type ConnectMethod = Exclude type IntegrationAttempt = IntegrationConnectOauthOutput["data"] type OnIntegrationConnected = (providerID?: string) => void +type WebSearchProvider = { id: string; name: string } export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -59,27 +60,30 @@ export function DialogIntegration( const data = useData() const dialog = useDialog() const { theme } = useTheme() - const options = createMemo(() => - integrationOptions(data.location.integration.list() ?? []) - .filter((integration) => props.integrationID === undefined || integration.id === props.integrationID) - .map((integration) => { + const options = createMemo(() => { + const providers = data.location.websearch.list() ?? [] + const providersByID = new Map(providers.map((provider) => [provider.id, provider])) + const integrations = integrationOptions(data.location.integration.list() ?? []).filter( + (integration) => props.integrationID === undefined || integration.id === props.integrationID, + ) + const integrationIDs = new Set(integrations.map((integration) => integration.id)) + return [ + ...integrations.map((integration) => { const methods = connectMethods(integration) const connected = integration.connections.length > 0 - const websearch = integration.websearch - const selected = data.location.websearch.provider() === integration.id + const provider = providersByID.get(integration.id) + const selected = data.location.websearch.provider() === provider?.id const credentials = credentialConnections(integration) - const description = websearch?.connection === "optional" ? "API key optional" : undefined - const category = websearch ? "Web search" : undefined return { title: integration.name, value: integration.id, - description: description ?? (methods.length === 0 ? "Environment only" : undefined), + description: methods.length === 0 ? "Environment only" : undefined, footer: [connectionSummary(integration), selected ? "Web search default" : undefined] .filter((value) => value !== undefined && value.length > 0) .join(" · ") || undefined, - category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"), - disabled: methods.length === 0 && !websearch, + category: provider ? "Web search" : integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services", + disabled: methods.length === 0 && !provider, gutter: connected ? () => : undefined, onSelect: () => { if (props.connectionOnly) { @@ -87,14 +91,24 @@ export function DialogIntegration( ? manageConnections(integration, methods, dialog, props.onConnected) : selectMethod(integration, methods, dialog, props.onConnected) } - if (websearch) return manageIntegration(integration, methods, websearch, dialog) + if (provider) return manageWebSearch(provider, dialog, integration, methods) return credentials.length ? manageConnections(integration, methods, dialog, props.onConnected) : selectMethod(integration, methods, dialog, props.onConnected) }, } }), - ) + ...providers + .filter((provider) => props.integrationID === undefined && !integrationIDs.has(provider.id)) + .map((provider) => ({ + title: provider.name, + value: provider.id, + footer: data.location.websearch.provider() === provider.id ? "Web search default" : undefined, + category: "Web search", + onSelect: () => manageWebSearch(provider, dialog), + })), + ] + }) return ( , +function manageWebSearch( + provider: WebSearchProvider, dialog: ReturnType, + integration?: IntegrationInfo, + methods: ConnectMethod[] = [], ) { - const connected = integration.connections.length > 0 dialog.replace(() => { const data = useData() const sdk = useSDK() const toast = useToast() - const credentials = credentialConnections(integration) - const selected = createMemo(() => data.location.websearch.provider() === integration.id) + const credentials = integration ? credentialConnections(integration) : [] + const selected = createMemo(() => data.location.websearch.provider() === provider.id) const selectWebSearch = () => { void sdk.api.websearch.provider .select({ - providerID: integration.id, + providerID: provider.id, location: location(data), }) .then(async () => { - await Promise.all([data.location.integration.refresh(), data.location.websearch.refresh()]) - toast.show({ variant: "success", message: `${integration.name} is now the web search default` }) + await Promise.all([ + ...(integration ? [data.location.integration.refresh()] : []), + data.location.websearch.refresh(), + ]) + toast.show({ variant: "success", message: `${provider.name} is now the web search default` }) dialog.clear() }) .catch(toast.error) @@ -138,18 +154,15 @@ function manageIntegration( return ( selectMethod(integration, methods, dialog, selectWebSearch) - : selectWebSearch, + onSelect: selectWebSearch, }, - ...(methods.length + ...(integration && methods.length ? [ { title: credentials.length ? "Manage connections" : "Connect", diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 267ccc6c83..6dc005c52f 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -7,7 +7,6 @@ import type { AgentInfo, CommandInfo, FormFormInfo, - FormIntegrationInfo, FormUrlInfo, IntegrationInfo, LocationRef, @@ -39,7 +38,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") // Global MCP elicitations temporarily use "global" instead of a real session ID, so the // server cannot recover their Location when settling them. Preserve the event Location // until MCP elicitations carry session ownership. -export type FormInfo = (FormFormInfo | FormUrlInfo | FormIntegrationInfo) & { readonly location?: LocationRef } +export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef } type LocationData = { agent?: AgentInfo[] @@ -49,7 +48,8 @@ type LocationData = { model?: ModelInfo[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] - websearchProvider?: string | null + websearch?: { id: string; name: string }[] + websearchSelected?: string | null // Currently running shell commands for this location, keyed by shell id. Entries are removed // once the command exits or is deleted, so this only ever holds in-flight shells. shell?: Record @@ -797,6 +797,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "config.updated": void result.location.websearch.refresh(event.location) break + case "websearch.updated": + void result.location.websearch.refresh(event.location) + break // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, // so the mcp list refreshes here rather than off integration.updated. case "mcp.status.changed": @@ -1006,13 +1009,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, websearch: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.websearch + }, provider(location?: LocationRef) { - return store.location[locationKey(location ?? defaultLocation())]?.websearchProvider ?? undefined + return store.location[locationKey(location ?? defaultLocation())]?.websearchSelected ?? undefined }, async refresh(ref?: LocationRef) { - const result = await sdk.api.websearch.provider.get({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], websearchProvider: result.data ?? null }) + const location = { location: locationQuery(ref ?? defaultLocation()) } + const [providers, selected] = await Promise.all([ + sdk.api.websearch.provider.list(location), + sdk.api.websearch.provider.selected(location), + ]) + const key = locationKey(providers.location) + setStore("location", key, { + ...store.location[key], + websearch: mutable(providers.data), + websearchSelected: selected.data ?? null, + }) }, }, skill: { diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 2f9f959f97..f66bea96df 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -10,8 +10,6 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../ui/border" import { useTuiConfig } from "../../config" import { useBindings, useOpencodeModeStack } from "../../keymap" -import { DialogIntegration } from "../../component/dialog-integration" -import { useDialog } from "../../ui/dialog" const FORM_MODE = "form" @@ -144,51 +142,9 @@ function requestOptions(form: FormInfo) { export function FormPrompt(props: { form: FormInfo }) { if (props.form.mode === "url") return - if (props.form.mode === "integration") return return } -function IntegrationPrompt(props: { form: FormInfo & { mode: "integration" } }) { - const sdk = useSDK() - const dialog = useDialog() - const { theme } = useTheme() - let settled = false - - onMount(() => { - dialog.replace( - () => ( - { - settled = true - dialog.clear() - void sdk.api.form.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer: {} }) - }} - /> - ), - () => { - if (settled) return - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }) - }, - ) - }) - - return ( - - - {props.form.title ?? "Connect integration"} - Complete the connection dialog to continue. - - - ) -} - function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { const sdk = useSDK() const { theme } = useTheme() diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 5cdcb51e89..3006aaf498 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -66,6 +66,9 @@ test("refreshes resources into reactive getters", async () => { location, data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }], }) + if (url.pathname === "/api/websearch/provider") + return json({ location, data: [{ id: "standalone", name: "Standalone" }] }) + if (url.pathname === "/api/websearch/provider/selected") return json({ location, data: "standalone" }) return undefined }, events) let data!: ReturnType @@ -101,6 +104,7 @@ test("refreshes resources into reactive getters", async () => { await data.session.refresh("ses_test") await data.session.message.refresh("ses_test") await data.location.agent.refresh() + await data.location.websearch.refresh() expect(data.session.get("ses_test")?.title).toBe("Test session") expect(data.session.message.ids("ses_test")).toEqual(["msg_first", "msg_second"]) @@ -109,6 +113,8 @@ test("refreshes resources into reactive getters", async () => { expect(app.captureCharFrame()).toContain("msg_second") expect(data.location.default()).toEqual({ directory, workspaceID: undefined }) expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"]) + expect(data.location.websearch.list(location)).toEqual([{ id: "standalone", name: "Standalone" }]) + expect(data.location.websearch.provider(location)).toBe("standalone") } finally { app.renderer.destroy() } diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index bf3f9962dd..7837dfa582 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -121,6 +121,9 @@ export function createFetch(override?: FetchHandler, events?: ReturnType