Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a56a8fe
feat(provider): discover models from openai-compatible providers
androidand May 14, 2026
5e36b13
feat(local): mDNS-based local provider discovery for /connect
androidand May 14, 2026
7690786
fix(local): reuse existing provider key when same baseURL already con…
androidand May 14, 2026
c34f9c8
feat(local): probe localhost ports to discover local llama-swap instance
androidand May 14, 2026
5b65bb9
fix(local): use txt.host for clean display name in mDNS scan results
androidand May 14, 2026
c707311
feat(tui): add local LAN provider discovery to /connect
androidand May 14, 2026
76d3dce
fix(local): graceful mDNS failure + better TUI error messages
androidand May 14, 2026
75bdaef
fix(tui/local): show configured instances instead of hiding them
androidand May 14, 2026
e0caa66
feat(local/scan): probe configured fleet providers to surface cross-s…
androidand May 14, 2026
9d29743
fix(local): reliable LAN scan + multi-select provider dialog
androidand May 14, 2026
2709771
Merge origin/dev into pr-27554 (resolve provider discovery conflict)
androidand May 15, 2026
9e3f189
Merge remote-tracking branch 'origin/dev' into pr-27554
androidand May 15, 2026
1418a9c
fix(run): adapt event consumers to regenerated sdk types
androidand May 15, 2026
bf2f693
Merge branch 'dev' into dev
androidand May 15, 2026
cd8d36b
Merge branch 'dev' into dev
androidand May 15, 2026
ceded06
fix(run): adapt event consumers to regenerated sdk types
androidand May 15, 2026
2cdc07f
fix(local): add Ollama (11434) and LM Studio (1234) to LAN scan ports
androidand May 15, 2026
0d37954
Merge remote-tracking branch 'origin/dev' into pr-27554
androidand May 15, 2026
230910e
fix(provider): refresh discovered local model metadata
androidand May 15, 2026
a9db1cc
Merge remote-tracking branch 'fork/pr-27554' into pr-27554
androidand May 15, 2026
4c4131a
Merge remote-tracking branch 'origin/dev' into pr-27554
androidand May 15, 2026
f44da50
Merge branch 'dev' into dev
androidand May 15, 2026
68f7242
feat(tui): show context window size for local models
androidand May 15, 2026
73156c6
Merge remote-tracking branch 'fork/dev' into pr-27554
androidand May 15, 2026
aea8a22
fix(test): re-apply list(ctx) arg fixes after merge
androidand May 15, 2026
3382329
Merge branch 'dev' into dev
androidand May 15, 2026
89b0f11
fix run agent lookup in effect context
androidand May 15, 2026
602edd6
Merge remote-tracking branch 'origin/dev' into tmp-pr27554-agent-fix
androidand May 15, 2026
64cf4f9
fix provider test instance helper usage
androidand May 15, 2026
3678754
Merge branch 'dev' into dev
androidand May 16, 2026
91ad278
fix(tui): sidebar-only context window, reactive to model switches
androidand May 17, 2026
e6fe65c
style(tui): remove inline comment from context sidebar
androidand May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

233 changes: 233 additions & 0 deletions packages/app/src/components/dialog-local-discovery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import type { LocalInstance } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { For, Match, Show, Switch, createSignal } from "solid-js"
import { useSDK } from "@/context/sdk"

type ScanState = "idle" | "scanning" | "done" | "error"

export function DialogLocalDiscovery() {
const dialog = useDialog()
const sdk = useSDK()

const [scanState, setScanState] = createSignal<ScanState>("idle")
const [instances, setInstances] = createSignal<LocalInstance[]>([])
const [errorMsg, setErrorMsg] = createSignal<string>()
// Track configured/removed state locally so UI reflects changes without re-scan.
// Key: instance id, value: providerID string (added) or null (removed)
const [localConfig, setLocalConfig] = createSignal(new Map<string, string | null>())
const [pending, setPending] = createSignal(new Set<string>())

function goBack() {
void import("./dialog-select-provider").then((m) => {
dialog.show(() => <m.DialogSelectProvider />)
})
}

function effectiveProviderID(instance: LocalInstance): string | undefined {
const local = localConfig()
if (local.has(instance.id)) {
const v = local.get(instance.id)
return v ?? undefined
}
return instance.configuredProviderID
}

function isConfigured(instance: LocalInstance) {
return effectiveProviderID(instance) !== undefined
}

function setInstancePending(id: string, on: boolean) {
setPending((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
}

function scan() {
setScanState("scanning")
setInstances([])
setErrorMsg(undefined)
sdk.client.local
.scan({ directory: sdk.directory })
.then((res) => {
setInstances(res.data ?? [])
setScanState("done")
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
setErrorMsg(msg)
setScanState("error")
})
}

function addProvider(instance: LocalInstance) {
setInstancePending(instance.id, true)
sdk.client.local
.connect({
directory: sdk.directory,
localConnectPayload: { id: instance.id, name: instance.name, baseURL: instance.baseURL },
})
.then((res) => {
setLocalConfig((prev) => new Map(prev).set(instance.id, res.data ?? instance.id))
showToast({
variant: "success",
icon: "circle-check",
title: `Added ${instance.name}`,
description: "Restart OpenCode to use local providers.",
})
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
showToast({ title: `Failed to add ${instance.name}`, description: msg })
})
.finally(() => setInstancePending(instance.id, false))
}

function removeProvider(instance: LocalInstance) {
const providerID = effectiveProviderID(instance)
if (!providerID) return
setInstancePending(instance.id, true)
sdk.client.local
.disconnect({ providerID, directory: sdk.directory })
.then(() => {
setLocalConfig((prev) => new Map(prev).set(instance.id, null))
showToast({
variant: "success",
icon: "circle-check",
title: `Removed ${instance.name}`,
description: "Restart OpenCode to apply changes.",
})
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
showToast({ title: `Failed to remove ${instance.name}`, description: msg })
})
.finally(() => setInstancePending(instance.id, false))
}

function addAll() {
instances()
.filter((i) => !isConfigured(i))
.forEach((i) => addProvider(i))
}

const unconfiguredCount = () => instances().filter((i) => !isConfigured(i)).length

return (
<Dialog
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={goBack}
aria-label="Go back"
/>
}
>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<Icon name="server" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">Local providers</div>
</div>

<div class="px-2.5 flex flex-col gap-4">
<div class="text-14-regular text-text-base">
Scan your local network for llama-swap instances via mDNS and add them as providers.
</div>

<div class="flex items-center gap-3">
<Button
size="small"
variant="primary"
onClick={scan}
disabled={scanState() === "scanning"}
icon="magnifying-glass"
>
{scanState() === "scanning" ? "Scanning…" : scanState() === "idle" ? "Scan" : "Scan again"}
</Button>
<Show when={scanState() === "done" && unconfiguredCount() > 1}>
<Button size="small" variant="ghost" onClick={addAll}>
Add all ({unconfiguredCount()})
</Button>
</Show>
</div>

<Switch>
<Match when={scanState() === "scanning"}>
<div class="flex items-center gap-x-2 text-14-regular text-text-base">
<Spinner />
<span>Scanning local network…</span>
</div>
</Match>

<Match when={scanState() === "error"}>
<div class="flex items-center gap-x-2 text-14-regular text-text-critical-base">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{errorMsg()}</span>
</div>
</Match>

<Match when={scanState() === "done" && instances().length === 0}>
<div class="text-14-regular text-text-weak">No local instances found.</div>
</Match>

<Match when={scanState() === "done" && instances().length > 0}>
<div class="flex flex-col gap-2">
<For each={instances()}>
{(instance) => (
<div class="flex items-center justify-between gap-3 px-2.5 py-2 rounded-md bg-surface-base">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-14-medium text-text-strong truncate">{instance.name}</span>
<span class="text-12-regular text-text-weak truncate">
{instance.host}:{instance.port}
<Show when={instance.models.length > 0}>
{" "}· {instance.models.length} model{instance.models.length !== 1 ? "s" : ""}
</Show>
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={isConfigured(instance)}>
<span class="text-12-regular text-text-success-base flex items-center gap-1">
<Icon name="circle-check" class="size-3.5 text-icon-success-base" />
Added
</span>
<Button
size="small"
variant="ghost"
disabled={pending().has(instance.id)}
onClick={() => removeProvider(instance)}
>
Remove
</Button>
</Show>
<Show when={!isConfigured(instance)}>
<Button
size="small"
variant="primary"
disabled={pending().has(instance.id)}
onClick={() => addProvider(instance)}
>
{pending().has(instance.id) ? "Adding…" : "Add"}
</Button>
</Show>
</div>
</div>
)}
</For>
</div>
</Match>
</Switch>
</div>
</div>
</Dialog>
)
}
16 changes: 14 additions & 2 deletions packages/app/src/components/dialog-select-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Component, Show } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { List } from "@opencode-ai/ui/list"
import { Tag } from "@opencode-ai/ui/tag"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
Expand All @@ -10,6 +11,7 @@ import { useLanguage } from "@/context/language"
import { DialogCustomProvider } from "./dialog-custom-provider"

const CUSTOM_ID = "_custom"
const LOCAL_ID = "_local"

export const DialogSelectProvider: Component = () => {
const dialog = useDialog()
Expand All @@ -35,13 +37,15 @@ export const DialogSelectProvider: Component = () => {
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
return [{ id: CUSTOM_ID, name: customLabel() }, { id: LOCAL_ID, name: "Local providers" }, ...providers.all()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
sortBy={(a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
if (a.id === LOCAL_ID) return -1
if (b.id === LOCAL_ID) return 1
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
Expand All @@ -58,12 +62,20 @@ export const DialogSelectProvider: Component = () => {
dialog.show(() => <DialogCustomProvider back="providers" />)
return
}
if (x.id === LOCAL_ID) {
void import("./dialog-local-discovery").then((m) => {
dialog.show(() => <m.DialogLocalDiscovery />)
})
return
}
dialog.show(() => <DialogConnectProvider provider={x.id} />)
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
<Show when={i.id === LOCAL_ID} fallback={<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />}>
<Icon name="server" data-slot="list-item-extra-icon" class="size-4 text-icon-base" />
</Show>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
Expand Down
8 changes: 0 additions & 8 deletions packages/app/src/context/global-sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}

const flush = () => {
Expand Down Expand Up @@ -165,10 +161,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
"bonjour-service": "^1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export class Agent implements ACPAgent {
}
}

private async handleEvent(event: Event) {
private async handleEvent(event: any) {
switch (event.type) {
case "permission.asked": {
const permission = event.properties
Expand Down
Loading
Loading