diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx
index 245be7d05ac..29740a8f947 100644
--- a/packages/tui/src/component/dialog-integration.tsx
+++ b/packages/tui/src/component/dialog-integration.tsx
@@ -9,6 +9,7 @@ import type {
FormField,
FormFields,
FormValue,
+ LocationRef,
} from "@opencode-ai/client"
import open from "open"
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
@@ -16,6 +17,7 @@ import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
+import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
@@ -74,10 +76,12 @@ export function DialogIntegration(
props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {},
) {
const data = useData()
+ const currentLocation = useLocation()
const dialog = useDialog()
const theme = useTheme("elevated")
+ const location = currentLocation.ref ?? data.location.default()
const integrations = createMemo(() =>
- integrationOptions(data.location.integration.list() ?? []).filter(
+ integrationOptions(data.location.integration.list(location) ?? []).filter(
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
),
)
@@ -88,14 +92,14 @@ export function DialogIntegration(
if (!integration) return
const methods = connectMethods(integration)
if (credentialConnections(integration).length) {
- manageConnections(integration, methods, dialog, props.onConnected)
+ manageConnections(integration, methods, location, dialog, props.onConnected)
return
}
- selectMethod(integration, methods, dialog, props.onConnected)
+ selectMethod(integration, methods, location, dialog, props.onConnected)
})
const options = createMemo(() => {
- const providers = data.location.websearch.list() ?? []
+ const providers = data.location.websearch.list(location) ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
return integrations().map((integration) => {
const methods = connectMethods(integration)
@@ -117,8 +121,8 @@ export function DialogIntegration(
? () => ✓
: undefined,
onSelect: () => {
- if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected)
- return selectMethod(integration, methods, dialog, props.onConnected)
+ if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
+ return selectMethod(integration, methods, location, dialog, props.onConnected)
},
}
})
@@ -145,6 +149,7 @@ export function DialogIntegration(
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
+ location: LocationRef,
dialog: ReturnType,
onConnected?: OnIntegrationConnected,
) {
@@ -156,7 +161,9 @@ function manageConnections(
const shortcuts = Keymap.useShortcuts()
const [deleting, setDeleting] = createSignal()
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
- const current = createMemo(() => data.location.integration.list()?.find((item) => item.id === integration.id))
+ const current = createMemo(() =>
+ data.location.integration.list(location)?.find((item) => item.id === integration.id),
+ )
return (
selectMethod(current() ?? integration, methods, dialog, onConnected),
+ onSelect: () => selectMethod(current() ?? integration, methods, location, dialog, onConnected),
},
]
: []),
@@ -193,7 +200,7 @@ function manageConnections(
onSelect: () => {
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential
- .activate({ credentialID: connection.id, location: location(data) })
+ .activate({ credentialID: connection.id, location: locationQuery(location) })
.catch(toast.error)
},
}
@@ -217,8 +224,8 @@ function manageConnections(
const label = value.trim()
if (!label) return
void client.api.credential
- .update({ credentialID: option.value, label, location: location(data) })
- .then(() => manageConnections(integration, methods, dialog, onConnected))
+ .update({ credentialID: option.value, label, location: locationQuery(location) })
+ .then(() => manageConnections(integration, methods, location, dialog, onConnected))
.catch(toast.error)
}}
/>
@@ -234,7 +241,7 @@ function manageConnections(
if (deleting() !== option.value) return setDeleting(option.value)
const final = credentialConnections(current() ?? integration).length === 1
void client.api.credential
- .remove({ credentialID: option.value, location: location(data) })
+ .remove({ credentialID: option.value, location: locationQuery(location) })
.then(() => {
setDeleting(undefined)
if (!final) return
@@ -256,17 +263,18 @@ function manageConnections(
function selectMethod(
integration: IntegrationInfo,
methods: ConnectMethod[],
+ location: LocationRef,
dialog: ReturnType,
onConnected?: OnIntegrationConnected,
) {
- if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected)
+ if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected)
dialog.replace(() => (
({
title: method.type === "key" ? (method.label ?? "API key") : method.label,
value: method.type === "key" ? "key" : method.id,
- onSelect: () => openMethod(integration, method, dialog, onConnected),
+ onSelect: () => openMethod(integration, method, location, dialog, onConnected),
}))}
/>
))
@@ -275,23 +283,27 @@ function selectMethod(
function openMethod(
integration: IntegrationInfo,
method: ConnectMethod,
+ location: LocationRef,
dialog: ReturnType,
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
- void beginKey(integration, method, dialog, onConnected)
+ void beginKey(integration, method, location, dialog, onConnected)
return
}
if (method.type === "command") {
- dialog.replace(() => )
+ dialog.replace(() => (
+
+ ))
return
}
- void beginOAuth(integration, method, dialog, onConnected)
+ void beginOAuth(integration, method, location, dialog, onConnected)
}
async function beginKey(
integration: IntegrationInfo,
method: Extract,
+ location: LocationRef,
dialog: ReturnType,
onConnected?: OnIntegrationConnected,
) {
@@ -300,16 +312,16 @@ async function beginKey(
: undefined
if (answer === null) return
dialog.replace(() => (
-
+
))
}
function CommandStarting(props: {
integration: IntegrationInfo
method: Extract
+ location: LocationRef
onConnected?: OnIntegrationConnected
}) {
- const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
@@ -321,14 +333,14 @@ function CommandStarting(props: {
.connect({
integrationID: props.integration.id,
methodID: props.method.id,
- location: location(data),
+ location: locationQuery(props.location),
})
.then((result) => {
if (closed) {
void client.api.integration.command.cancel({
integrationID: props.integration.id,
attemptID: result.data.attemptID,
- location: location(data),
+ location: locationQuery(props.location),
})
return
}
@@ -338,6 +350,7 @@ function CommandStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
+ location={props.location}
onConnected={props.onConnected}
/>
))
@@ -359,6 +372,7 @@ function CommandPending(props: {
integration: IntegrationInfo
title: string
attempt: CommandAttempt
+ location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -374,7 +388,7 @@ function CommandPending(props: {
.status({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
- location: location(data),
+ location: locationQuery(props.location),
})
.then((result) => {
const status = result.data
@@ -385,7 +399,7 @@ function CommandPending(props: {
}
settled = true
if (status.status === "complete") {
- void connected(props.integration, data, dialog, toast, props.onConnected)
+ void connected(props.integration, props.location, data, dialog, toast, props.onConnected)
return
}
toast.show({
@@ -408,7 +422,7 @@ function CommandPending(props: {
void client.api.integration.command.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
- location: location(data),
+ location: locationQuery(props.location),
})
})
@@ -449,6 +463,7 @@ function CommandView(props: { title: string; output: string; message: string })
function KeyMethod(props: {
integration: IntegrationInfo
method: Extract
+ location: LocationRef
answer?: FormAnswer
onConnected?: OnIntegrationConnected
}) {
@@ -468,11 +483,11 @@ function KeyMethod(props: {
void client.api.integration.connect
.key({
integrationID: props.integration.id,
- location: location(data),
+ location: locationQuery(props.location),
key,
...(props.answer ? { answer: props.answer } : {}),
})
- .then(() => connected(props.integration, data, dialog, toast, props.onConnected))
+ .then(() => connected(props.integration, props.location, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
}}
description={() => (
@@ -485,23 +500,30 @@ function KeyMethod(props: {
async function beginOAuth(
integration: IntegrationInfo,
method: IntegrationOAuthMethod,
+ location: LocationRef,
dialog: ReturnType,
onConnected?: OnIntegrationConnected,
) {
const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined
if (answer === null) return
dialog.replace(() => (
-
+
))
}
function OAuthStarting(props: {
integration: IntegrationInfo
method: IntegrationOAuthMethod
+ location: LocationRef
answer?: FormAnswer
onConnected?: OnIntegrationConnected
}) {
- const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
@@ -510,7 +532,7 @@ function OAuthStarting(props: {
void client.api.integration.oauth
.connect({
integrationID: props.integration.id,
- location: location(data),
+ location: locationQuery(props.location),
methodID: props.method.id,
...(props.answer ? { answer: props.answer } : {}),
})
@@ -521,6 +543,7 @@ function OAuthStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
+ location={props.location}
onConnected={props.onConnected}
/>
))
@@ -531,6 +554,7 @@ function OAuthStarting(props: {
integration={props.integration}
title={props.method.label}
attempt={result.data}
+ location={props.location}
onConnected={props.onConnected}
/>
))
@@ -548,6 +572,7 @@ function OAuthAuto(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
+ location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -591,7 +616,11 @@ function OAuthAuto(props: {
const poll = () => {
void client.api.integration.oauth
- .status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data) })
+ .status({
+ integrationID: props.integration.id,
+ attemptID: props.attempt.attemptID,
+ location: locationQuery(props.location),
+ })
.then((result) => {
const status = result.data
if (status.status === "pending") {
@@ -600,7 +629,7 @@ function OAuthAuto(props: {
}
settled = true
if (status.status === "complete") {
- void connected(props.integration, data, dialog, toast, props.onConnected)
+ void connected(props.integration, props.location, data, dialog, toast, props.onConnected)
return
}
toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" })
@@ -620,7 +649,7 @@ function OAuthAuto(props: {
void client.api.integration.oauth.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
- location: location(data),
+ location: locationQuery(props.location),
})
})
@@ -640,6 +669,7 @@ function OAuthCode(props: {
integration: IntegrationInfo
title: string
attempt: IntegrationAttempt
+ location: LocationRef
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -655,7 +685,7 @@ function OAuthCode(props: {
void client.api.integration.oauth.cancel({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
- location: location(data),
+ location: locationQuery(props.location),
})
})
@@ -669,12 +699,12 @@ function OAuthCode(props: {
.complete({
integrationID: props.integration.id,
attemptID: props.attempt.attemptID,
- location: location(data),
+ location: locationQuery(props.location),
code,
})
.then(() => {
settled = true
- return connected(props.integration, data, dialog, toast, props.onConnected)
+ return connected(props.integration, props.location, data, dialog, toast, props.onConnected)
})
.catch((cause) => setError(message(cause)))
}}
@@ -968,26 +998,31 @@ async function externalAnswer(
async function connected(
integration: IntegrationInfo,
+ location: LocationRef,
data: ReturnType,
dialog: ReturnType,
toast: ReturnType,
onConnected?: OnIntegrationConnected,
) {
- data.location.integration.invalidate()
- data.location.model.invalidate()
- data.location.provider.invalidate()
- await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()])
+ data.location.integration.invalidate(location)
+ data.location.model.invalidate(location)
+ data.location.provider.invalidate(location)
+ await Promise.all([
+ data.location.integration.sync(location),
+ data.location.model.sync(location),
+ data.location.provider.sync(location),
+ ])
toast.show({ variant: "success", message: `Connected ${integration.name}` })
if (onConnected) {
- onConnected(providerID(data, integration.id))
+ onConnected(providerID(data, location, integration.id))
return
}
dialog.clear()
}
-function providerID(data: ReturnType, integrationID: string) {
- const models = data.location.model.list() ?? []
- const matches = (data.location.provider.list() ?? []).filter(
+function providerID(data: ReturnType, location: LocationRef, integrationID: string) {
+ const models = data.location.model.list(location) ?? []
+ const matches = (data.location.provider.list(location) ?? []).filter(
(provider) => provider.integrationID === integrationID || provider.id === integrationID,
)
return (
@@ -997,9 +1032,8 @@ function providerID(data: ReturnType, integrationID: string) {
)
}
-function location(data: ReturnType) {
- const current = data.location.default()
- return { directory: current.directory, workspace: current.workspaceID }
+function locationQuery(location: LocationRef) {
+ return { directory: location.directory, workspace: location.workspaceID }
}
function message(cause: unknown) {
diff --git a/packages/tui/test/cli/tui/dialog-integration.test.tsx b/packages/tui/test/cli/tui/dialog-integration.test.tsx
index 82306abc854..ba52519e872 100644
--- a/packages/tui/test/cli/tui/dialog-integration.test.tsx
+++ b/packages/tui/test/cli/tui/dialog-integration.test.tsx
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable, TextareaRenderable } from "@opentui/core"
+import type { LocationRef } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
@@ -8,7 +9,7 @@ import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
-import { LocationProvider } from "../../../src/context/location"
+import { LocationProvider, useLocation } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
@@ -203,9 +204,27 @@ test("hides account rename and delete actions while the add account row is selec
}
})
-async function renderIntegration() {
+test("uses the active location for integration data and credential requests", async () => {
+ const location = { directory: "/remote/project", workspaceID: "workspace_test" }
+ const fixture = await renderIntegration(location)
+
+ try {
+ fixture.app.mockInput.pressArrow("down")
+ fixture.app.mockInput.pressArrow("down")
+ fixture.app.mockInput.pressEnter()
+
+ await fixture.app.waitFor(() => fixture.requests.length === 1)
+ expect(fixture.locations).toContainEqual(location)
+ expect(fixture.locations.at(-1)).toEqual(location)
+ } finally {
+ fixture.app.renderer.destroy()
+ }
+})
+
+async function renderIntegration(activeLocation?: LocationRef) {
const events = createEventStream()
const requests: Array<{ method: string; path: string; body?: { label: string } }> = []
+ const locations: LocationRef[] = []
const reads = { integration: 0, model: 0, provider: 0 }
let accounts = [
{ type: "credential" as const, id: "cred_personal", label: "Personal" },
@@ -213,12 +232,19 @@ async function renderIntegration() {
]
const calls = createFetch(async (url, request) => {
+ const directory =
+ url.searchParams.get("location[directory]") ??
+ decodeURIComponent(request.headers.get("x-opencode-directory") ?? process.cwd())
+ const workspaceID =
+ url.searchParams.get("location[workspace]") ?? request.headers.get("x-opencode-workspace") ?? undefined
+ const requestedLocation = { directory, ...(workspaceID ? { workspaceID } : {}) }
const location = {
- directory: process.cwd(),
- project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
+ ...requestedLocation,
+ project: { id: "proj_test", directory, canonical: directory },
}
if (url.pathname === "/api/integration") {
+ locations.push(requestedLocation)
reads.integration++
return json({
location,
@@ -244,6 +270,7 @@ async function renderIntegration() {
}
if (request.method === "POST" && /^\/api\/credential\/[^/]+\/activate$/.test(url.pathname)) {
+ locations.push(requestedLocation)
const id = url.pathname.split("/")[3]
const active = accounts.find((account) => account.id === id)
if (!active) throw new Error(`unknown credential: ${id}`)
@@ -289,9 +316,11 @@ async function renderIntegration() {
function Probe() {
const data = useData()
const dialog = useDialog()
+ const location = useLocation()
onMount(() => {
+ location.set(activeLocation)
void data.location.integration
- .sync()
+ .sync(activeLocation)
.then(() => dialog.replace(() => ))
})
return null
@@ -332,6 +361,7 @@ async function renderIntegration() {
app,
reads,
requests,
+ locations,
get accounts() {
return accounts
},