feat(ui): manage visible provider models (#637)

## Summary
- add per-provider model visibility controls to Settings > Providers and
the floating provider dialog
- persist exact hidden model IDs as CodeNomad presentation preferences
- keep large plugin catalogs manageable with search and Show all / Hide
all actions

## Behavior
- all current and newly reported models remain visible by default
- unchecked models are hidden only from model picker lists
- active/default models remain usable even when hidden
- favorites remain persisted and return when their models are shown
again
- the same management UI is available from Settings and the floating
model-picker dialog

This intentionally mirrors OpenChamber/OpenCode Desktop client-side
hiding. It does not rewrite OpenCode configuration or change model
execution/default semantics.

## Validation
- npm run typecheck
- node --import tsx --test packages/ui/src/lib/model-visibility.test.ts
- npm run build --workspace @codenomad/ui
- Gatekeeper reviews: PASS

Closes #636
This commit is contained in:
Pascal André 2026-08-13 10:33:23 +02:00 committed by GitHub
parent b8e7b16350
commit 3bf3f584ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1010 additions and 87 deletions

View file

@ -110,6 +110,7 @@ jobs:
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
packages/ui/src/lib/launch-errors.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/model-visibility.test.ts
packages/ui/src/lib/runtime-env.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts

View file

@ -0,0 +1,58 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { SettingsService } from "./service"
function serviceWithStore(store: Record<string, unknown>) {
const service = Object.create(SettingsService.prototype) as SettingsService
Object.assign(service as any, { configStore: store, eventBus: undefined })
return service
}
describe("SettingsService config persistence", () => {
it("normalizes and persists a document patch once", () => {
let writes = 0
const service = serviceWithStore({
get: () => ({ server: { logLevel: "info" } }),
replace: (value: unknown) => {
writes += 1
return value
},
mergePatch: () => assert.fail("must not persist an intermediate document"),
})
const result = service.mergePatchDoc("config", { ui: { theme: "dark" } })
assert.equal(writes, 1)
assert.deepEqual(result, { server: { logLevel: "INFO" }, ui: { theme: "dark" } })
})
it("normalizes and persists a server-owner patch once", () => {
let writes = 0
const service = serviceWithStore({
getOwner: () => ({ logLevel: "info", sidecars: [] }),
replaceOwner: (_owner: string, value: unknown) => {
writes += 1
return value
},
mergePatchOwner: () => assert.fail("must not persist an intermediate owner"),
})
const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] })
assert.equal(writes, 1)
assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] })
})
it("does not report a persisted patch as failed when an event listener throws", () => {
let warnings = 0
const service = serviceWithStore({
getOwner: () => ({}),
mergePatchOwner: (_owner: string, patch: unknown) => patch,
})
Object.assign(service as any, {
eventBus: { publish: () => { throw new Error("listener failed") } },
logger: { warn: () => { warnings += 1 } },
})
assert.deepEqual(service.mergePatchOwner("config", "ui", { theme: "dark" }), { theme: "dark" })
assert.equal(warnings, 1)
})
})

View file

@ -6,6 +6,7 @@ import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
import { sanitizeConfigOwner } from "./public-config"
import { applyMergePatch } from "./merge-patch"
export type DocKind = "config" | "state"
@ -67,8 +68,16 @@ export class SettingsService {
private readonly logger: Logger,
) {
migrateSettingsLayout(location, logger)
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
this.configStore = new YamlDocStore(
location.configYamlPath,
logger.child({ component: "settings-config" }),
{ throwOnPersistError: true },
)
this.stateStore = new YamlDocStore(
location.stateYamlPath,
logger.child({ component: "settings-state" }),
{ throwOnPersistError: true },
)
}
getDoc(kind: DocKind): SettingsDoc {
@ -85,9 +94,12 @@ export class SettingsService {
}
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
if (!isPlainObject(patch)) {
throw new Error("Patch must be a JSON object")
}
const updated =
kind === "config"
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
? this.configStore.replace(normalizeConfigDoc(applyMergePatch(this.configStore.get(), patch) as SettingsDoc))
: this.stateStore.mergePatch(patch)
this.publish(kind, "*")
return updated
@ -104,10 +116,16 @@ export class SettingsService {
}
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
if (!isPlainObject(patch)) {
throw new Error("Patch must be a JSON object")
}
const updated =
kind === "config"
? owner === "server"
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
? this.configStore.replaceOwner(
owner,
normalizeServerConfigOwner(applyMergePatch(this.configStore.getOwner(owner), patch) as SettingsDoc),
)
: this.configStore.mergePatchOwner(owner, patch)
: this.stateStore.mergePatchOwner(owner, patch)
this.publish(kind, owner, updated)
@ -123,6 +141,10 @@ export class SettingsService {
owner,
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
} as any
this.eventBus.publish(payload)
try {
this.eventBus.publish(payload)
} catch (error) {
this.logger.warn({ err: error, kind, owner }, "Failed to publish settings change")
}
}
}

View file

@ -0,0 +1,84 @@
import assert from "node:assert/strict"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { describe, it } from "node:test"
import { YamlDocStore } from "./yaml-doc-store"
describe("YamlDocStore", () => {
it("reports persistence failures without replacing the cached document", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
const parent = path.join(root, "settings")
const file = path.join(parent, "config.yaml")
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
try {
store.replace({ version: 1 })
fs.rmSync(parent, { recursive: true })
fs.writeFileSync(parent, "blocks directory creation")
assert.throws(() => store.replace({ version: 2 }))
assert.deepEqual(store.get(), { version: 1 })
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
it("keeps the live document intact when atomic replacement fails", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
const file = path.join(root, "config.yaml")
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
const renameSync = fs.renameSync
try {
store.replace({ version: 1 })
;(fs as any).renameSync = () => { throw new Error("replacement failed") }
assert.throws(() => store.replace({ version: 2 }))
assert.match(fs.readFileSync(file, "utf8"), /version: 1/)
assert.deepEqual(store.get(), { version: 1 })
;(fs as any).renameSync = renameSync
store.replace({ version: 2 })
assert.match(fs.readFileSync(file, "utf8"), /version: 2/)
} finally {
;(fs as any).renameSync = renameSync
fs.rmSync(root, { recursive: true, force: true })
}
})
it("preserves private file permissions", { skip: process.platform === "win32" }, () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
const file = path.join(root, "config.yaml")
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
try {
store.replace({ version: 1 })
fs.chmodSync(file, 0o664)
store.replace({ version: 2 })
assert.equal(fs.statSync(file).mode & 0o777, 0o664)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
it("updates the final target without replacing a symlink chain", { skip: process.platform === "win32" }, () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
const target = path.join(root, "target.yaml")
const intermediate = path.join(root, "current.yaml")
const link = path.join(root, "config.yaml")
fs.writeFileSync(target, "version: 1\n")
fs.symlinkSync(target, intermediate)
fs.symlinkSync(intermediate, link)
const store = new YamlDocStore(link, { warn() {} } as any, { throwOnPersistError: true })
try {
store.replace({ version: 2 })
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
assert.equal(fs.lstatSync(intermediate).isSymbolicLink(), true)
assert.match(fs.readFileSync(target, "utf8"), /version: 2/)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
})

View file

@ -1,5 +1,6 @@
import fs from "fs"
import path from "path"
import { randomUUID } from "node:crypto"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import type { Logger } from "../logger"
import { applyMergePatch, isPlainObject } from "./merge-patch"
@ -18,6 +19,25 @@ function normalizeDoc(input: unknown): SettingsDoc {
return input
}
function resolveWriteDestination(filePath: string): string {
let current = path.resolve(filePath)
const seen = new Set<string>()
while (true) {
let stat: fs.Stats
try {
stat = fs.lstatSync(current)
} catch (error: any) {
if (error?.code === "ENOENT") return current
throw error
}
if (!stat.isSymbolicLink()) return current
if (seen.has(current)) throw new Error(`Circular settings symlink: ${filePath}`)
seen.add(current)
current = path.resolve(path.dirname(current), fs.readlinkSync(current))
}
}
export class YamlDocStore {
private cache: SettingsDoc = {}
private loaded = false
@ -25,6 +45,7 @@ export class YamlDocStore {
constructor(
private readonly filePath: string,
private readonly logger: Logger,
private readonly options: { throwOnPersistError?: boolean } = {},
) {}
load(): SettingsDoc {
@ -58,9 +79,17 @@ export class YamlDocStore {
replace(next: unknown): SettingsDoc {
const normalized = normalizeDoc(next)
const previousCache = this.cache
const previousLoaded = this.loaded
this.cache = normalized
this.loaded = true
this.persist()
try {
this.persist()
} catch (error) {
this.cache = previousCache
this.loaded = previousLoaded
throw error
}
return this.cache
}
@ -99,12 +128,27 @@ export class YamlDocStore {
}
private persist() {
let tempPath: string | undefined
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
const destination = resolveWriteDestination(this.filePath)
fs.mkdirSync(path.dirname(destination), { recursive: true })
const yaml = stringifyYaml(this.cache as any)
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
const mode = fs.existsSync(destination) ? fs.statSync(destination).mode & 0o777 : 0o600
tempPath = `${destination}.${process.pid}.${randomUUID()}.tmp`
fs.writeFileSync(tempPath, ensureTrailingNewline(yaml), { encoding: "utf-8", mode })
if (process.platform !== "win32") fs.chmodSync(tempPath, mode)
fs.renameSync(tempPath, destination)
} catch (error) {
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
if (this.options.throwOnPersistError) throw error
} finally {
if (tempPath) {
try {
fs.rmSync(tempPath, { force: true })
} catch (error) {
this.logger.warn({ err: error, tempPath }, "Failed to remove temporary YAML doc")
}
}
}
}
}

View file

@ -0,0 +1,47 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { SideCarManager } from "./manager"
const existing = {
id: "existing",
kind: "port",
name: "Existing",
port: 65534,
insecure: true,
prefixMode: "strip",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}
function manager(sidecars: unknown[] = []) {
return new SideCarManager({
settings: {
getOwner: () => ({ sidecars }),
mergePatchOwner: () => { throw new Error("disk full") },
} as any,
eventBus: { publish() {} } as any,
logger: { warn() {} } as any,
})
}
describe("SideCarManager persistence rollback", () => {
it("restores create, update, and delete state when persistence fails", async () => {
const createManager = manager()
await assert.rejects(createManager.create({
kind: "port",
name: "New",
port: 65533,
insecure: true,
prefixMode: "strip",
}))
assert.deepEqual(await createManager.list(), [])
const updateManager = manager([existing])
await assert.rejects(updateManager.update(existing.id, { name: "Changed" }))
assert.equal((await updateManager.get(existing.id))?.name, existing.name)
const deleteManager = manager([existing])
await assert.rejects(deleteManager.delete(existing.id))
assert.equal((await deleteManager.get(existing.id))?.id, existing.id)
})
})

View file

@ -82,7 +82,13 @@ export class SideCarManager {
this.configs.set(record.id, record)
this.runtime.set(record.id, { status: "stopped" })
this.persistConfigs()
try {
this.persistConfigs()
} catch (error) {
this.configs.delete(record.id)
this.runtime.delete(record.id)
throw error
}
await this.refreshPortSideCar(record.id)
return this.toSideCar(record)
}
@ -98,13 +104,19 @@ export class SideCarManager {
): Promise<SideCar> {
const record = this.requireConfig(id)
const previous = { ...record }
record.name = typeof input.name === "string" ? input.name.trim() : record.name
record.port = typeof input.port === "number" ? input.port : record.port
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
record.updatedAt = new Date().toISOString()
this.persistConfigs()
try {
this.persistConfigs()
} catch (error) {
this.configs.set(id, previous)
throw error
}
await this.refreshPortSideCar(id)
return this.toSideCar(record)
}
@ -113,9 +125,16 @@ export class SideCarManager {
const record = this.configs.get(id)
if (!record) return false
const runtime = this.runtime.get(id)
this.configs.delete(id)
this.runtime.delete(id)
this.persistConfigs()
try {
this.persistConfigs()
} catch (error) {
this.configs.set(id, record)
if (runtime) this.runtime.set(id, runtime)
throw error
}
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
return true
}

View file

@ -5,8 +5,9 @@ import { ChevronDown, PlugZap, Star } from "lucide-solid"
import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
import { getProviderModelVisibilityPreference, uiState, toggleFavoriteModelPreference } from "../stores/preferences"
import { ProviderManagerModal } from "./provider-auth/provider-manager-modal"
import { isModelVisible, resolvePickerValue } from "../lib/model-visibility"
const log = getLogger("session")
interface ModelSelectorProps {
@ -20,6 +21,7 @@ interface FlatModel extends Model {
providerName: string
key: string
searchText: string
unavailable?: boolean
}
interface ModelGroup {
@ -67,7 +69,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
}
})
const allModels = createMemo<FlatModel[]>(() =>
const catalogModels = createMemo<FlatModel[]>(() =>
instanceProviders().flatMap((p) =>
p.models.map((m) => ({
...m,
@ -78,6 +80,13 @@ export default function ModelSelector(props: ModelSelectorProps) {
),
)
const allModels = createMemo(() => catalogModels().filter((model) =>
isModelVisible(
getProviderModelVisibilityPreference(model.providerId),
model.id,
),
))
const sortedModels = createMemo<FlatModel[]>(() =>
[...allModels()].sort((left, right) => {
const providerComparison = compareIds(left.providerId, right.providerId)
@ -106,9 +115,22 @@ export default function ModelSelector(props: ModelSelectorProps) {
const hasFavorites = createMemo(() => favoriteModels().length > 0)
const currentModelValue = createMemo(() =>
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
)
const currentModelValue = createMemo<FlatModel | undefined>(() => {
const current = props.currentModel
const found = catalogModels().find((model) => model.providerId === current.providerId && model.id === current.modelId)
if (found) return found
if (!current.providerId || !current.modelId) return undefined
const providerName = instanceProviders().find((provider) => provider.id === current.providerId)?.name ?? current.providerId
return {
id: current.modelId,
name: t("modelSelector.unavailableModel", { model: current.modelId }),
providerId: current.providerId,
providerName,
key: `${current.providerId}/${current.modelId}`,
searchText: `${current.modelId} ${providerName} ${current.providerId}`,
unavailable: true,
}
})
const currentModelIsFavorite = createMemo(() => {
const current = props.currentModel
@ -134,10 +156,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
})
const visibleOptions = createMemo<FlatModel[]>(() => {
if (!favoritesOnlyEnabled()) {
return sortedModels()
}
return favoriteModels()
const visible = favoritesOnlyEnabled() ? favoriteModels() : sortedModels()
const current = currentModelValue()
return current?.unavailable ? [current, ...visible] : visible
})
const groupedVisibleOptions = createMemo<ModelGroup[]>(() => {
@ -169,8 +190,27 @@ export default function ModelSelector(props: ModelSelectorProps) {
]),
)
const comboboxValue = createMemo(() => {
const options = pickerOptions().filter((option): option is FlatModel => !isProviderHeaderOption(option))
return resolvePickerValue(currentModelValue(), options)
})
const currentModelLabel = createMemo(() =>
t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") }),
)
const currentModelAccessibleLabel = createMemo(() => {
const current = currentModelValue()
if (!current) return currentModelLabel()
return t("modelSelector.trigger.ariaLabel", {
model: current.name,
provider: current.providerName,
id: `${current.providerId}/${current.id}`,
})
})
const handleChange = async (value: PickerOption | null) => {
if (!value || isProviderHeaderOption(value)) return
if (!value || isProviderHeaderOption(value) || value.unavailable) return
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
}
@ -268,7 +308,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
<div class="sidebar-selector">
<Combobox<PickerOption>
open={isOpen()}
value={currentModelValue()}
value={comboboxValue()}
onChange={handleChange}
onOpenChange={(next) => {
if (!next && suppressNextClose) return
@ -278,7 +318,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
optionValue="key"
optionTextValue="searchText"
optionLabel={(option) => (isProviderHeaderOption(option) ? option.providerName : option.name)}
optionDisabled={isProviderHeaderOption}
optionDisabled={(option) => isProviderHeaderOption(option) || Boolean(option.unavailable)}
placeholder={t("modelSelector.placeholder.search")}
defaultFilter={customFilter}
allowsEmptyCollection
@ -314,56 +354,59 @@ export default function ModelSelector(props: ModelSelectorProps) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
<button
type="button"
class="selector-option-star"
data-active={isFavorite()}
aria-label={
isFavorite()
? t("modelSelector.favorite.remove")
: t("modelSelector.favorite.add")
}
onPointerDown={preventListboxPress}
onPointerUp={preventListboxPress}
onMouseDown={preventListboxPress}
onMouseUp={preventListboxPress}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
event.stopPropagation()
suppressNextClose = true
setTimeout(() => {
suppressNextClose = false
}, 0)
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoriteModelPreference({
providerId: model.providerId,
modelId: model.id,
})
}}
>
<Star
class="w-4 h-4"
fill={isFavorite() ? "currentColor" : "none"}
/>
</button>
{!model.unavailable && (
<button
type="button"
class="selector-option-star"
data-active={isFavorite()}
aria-label={
isFavorite()
? t("modelSelector.favorite.remove")
: t("modelSelector.favorite.add")
}
onPointerDown={preventListboxPress}
onPointerUp={preventListboxPress}
onMouseDown={preventListboxPress}
onMouseUp={preventListboxPress}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
event.stopPropagation()
suppressNextClose = true
setTimeout(() => {
suppressNextClose = false
}, 0)
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoriteModelPreference({
providerId: model.providerId,
modelId: model.id,
})
}}
>
<Star
class="w-4 h-4"
fill={isFavorite() ? "currentColor" : "none"}
/>
</button>
)}
</>
</Combobox.Item>
)
}}
>
<Combobox.Control class="relative w-full" data-model-selector-control>
<Combobox.Input class="sr-only" data-model-selector />
<Combobox.Input class="sr-only" data-model-selector aria-label={currentModelAccessibleLabel()} />
<Combobox.Trigger
ref={triggerRef}
class="selector-trigger"
aria-label={currentModelAccessibleLabel()}
>
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
{currentModelLabel()}
</span>
{currentModelValue() && (
<span class="selector-trigger-secondary" dir="ltr">

View file

@ -17,6 +17,10 @@ import {
} from "../../lib/provider-auth"
import { instances } from "../../stores/instances"
import { fetchProviders } from "../../stores/sessions"
import {
ProviderModelVisibilityManager,
type ProviderVisibilityModel,
} from "./provider-model-visibility-manager"
type AuthStage = "idle" | "prompts" | "authorizing" | "code" | "waiting" | "success" | "error"
@ -38,6 +42,7 @@ type ListedProvider = {
id: string
name: string
modelCount: number
models: ProviderVisibilityModel[]
source: "env" | "config" | "custom" | "api" | "unknown"
}
@ -50,11 +55,13 @@ interface ProviderManagerModalProps {
onOpenChange?: (open: boolean) => void
}
function modelCountFromProvider(provider: any): number {
function modelsFromProvider(provider: any): ProviderVisibilityModel[] {
const models = provider?.models
if (Array.isArray(models)) return models.length
if (models && typeof models === "object") return Object.keys(models).length
return 0
if (!models || typeof models !== "object" || Array.isArray(models)) return []
return Object.entries(models).map(([id, model]) => ({
id,
name: typeof (model as any)?.name === "string" ? (model as any).name : id,
}))
}
export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props) => {
@ -66,6 +73,9 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
const [configData, setConfigData] = createSignal<Record<string, any>>({})
const [selectedProviderId, setSelectedProviderId] = createSignal<string | null>(null)
const [activeProviderId, setActiveProviderId] = createSignal<string | null>(null)
const [managedProviderId, setManagedProviderId] = createSignal<string | null>(null)
const manageModelButtons = new Map<string, HTMLButtonElement>()
let managedProviderTriggerId: string | null = null
const [selectedMethodIndex, setSelectedMethodIndex] = createSignal(0)
const [apiKey, setApiKey] = createSignal("")
const [promptValues, setPromptValues] = createSignal<Record<string, string>>({})
@ -118,6 +128,10 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
availableProviders().filter((provider) => isConfiguredProvider(provider)),
)
const managedProvider = createMemo(() =>
configuredProviders().find((provider) => provider.id === managedProviderId()) ?? null,
)
const getDisconnectMode = (provider: ListedProvider): DisconnectMode => {
if (provider.source === "env") return "not-disconnectable"
if (provider.source === "config" || configuredProviderIds().has(provider.id)) return "disable-in-config"
@ -270,15 +284,19 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
if (version !== loadVersion) return
const nextConfigData = (configResponse?.data ?? {}) as Record<string, any>
const nextConfiguredIds = new Set(Object.keys((nextConfigData.provider ?? {}) as Record<string, unknown>))
const listed = ((providerListResponse?.data?.all ?? []) as any[]).map((provider) => ({
id: String(provider.id ?? ""),
name: String(provider.name ?? provider.id ?? ""),
modelCount: modelCountFromProvider(provider),
source:
provider?.source === "env" || provider?.source === "config" || provider?.source === "custom" || provider?.source === "api"
? provider.source
: "unknown",
})).filter((provider) => provider.id.length > 0)
const listed = ((providerListResponse?.data?.all ?? []) as any[]).map((provider) => {
const models = modelsFromProvider(provider)
return {
id: String(provider.id ?? ""),
name: String(provider.name ?? provider.id ?? ""),
modelCount: models.length,
models,
source:
provider?.source === "env" || provider?.source === "config" || provider?.source === "custom" || provider?.source === "api"
? provider.source
: "unknown",
} as ListedProvider
}).filter((provider) => provider.id.length > 0)
setAvailableProviders(listed)
setConnectedProviderIds(new Set((providerListResponse?.data?.connected ?? []) as string[]))
setConfiguredProviderIds(nextConfiguredIds)
@ -309,6 +327,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
setConfiguredProviderIds(new Set<string>())
setConfigData({})
setSelectedProviderId(null)
setManagedProviderId(null)
setLoadError(null)
setLoading(false)
}
@ -344,6 +363,23 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
await loadProviderData(authClient).catch(() => undefined)
}
async function refreshProviderData() {
const authClient = client()
const instanceId = props.instanceId
if (!authClient) return
setLoading(true)
await fetchProviders(instanceId).catch(() => undefined)
if (client() !== authClient || props.instanceId !== instanceId) return
await loadProviderData(authClient)
}
function closeModelManager() {
setManagedProviderId(null)
queueMicrotask(() => {
if (managedProviderTriggerId) manageModelButtons.get(managedProviderTriggerId)?.focus()
})
}
async function submitApiAuth(providerId: string, authClient: OpencodeClient, instanceId: string, operationVersion: number) {
await requestData(
(authClient as any).auth.set({ providerID: providerId, auth: { type: "api", key: apiKey().trim() } }),
@ -578,7 +614,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<button type="button" class="selector-button selector-button-primary" disabled={!selectedProviderOption()} onClick={() => resetFlow(selectedProviderOption()?.id ?? null)}>
{t("settings.providers.actions.connect")}
</button>
<button type="button" class="settings-pill-button" disabled={loading()} onClick={() => client() && void loadProviderData(client()!)}>
<button type="button" class="settings-pill-button" disabled={loading()} onClick={() => void refreshProviderData()}>
<RefreshCw class={loading() ? "providers-spin-icon" : "providers-button-icon"} />
{t("settings.providers.refresh")}
</button>
@ -694,16 +730,43 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
<section class="providers-list-section">
<h3 class="settings-card-title">{t("settings.providers.configured.title")}</h3>
<Show when={loading()}><div class="providers-loading-row" role="status"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
<Show when={!loading() && configuredProviders().length === 0}><div class="settings-card-message" role="status">{t("settings.providers.empty.noConfiguredProviders")}</div></Show>
<div class="providers-grid">
<For each={configuredProviders()}>{(provider) => (
<article class="providers-card">
<div class="providers-card-main"><div class="providers-card-mark"><ShieldCheck class="providers-card-mark-icon" /></div><div class="providers-card-copy"><div class="providers-card-title-row"><h4 class="providers-card-title">{provider.name || provider.id}</h4></div><p class="providers-card-meta">{provider.id}</p><p class="providers-card-methods">{methodSummary(provider.id)}</p><p class="providers-card-source">{describeProviderSource(provider)}</p></div></div>
<div class="providers-card-footer"><span class="providers-model-count">{provider.modelCount === 1 ? t("settings.providers.models.one", { count: provider.modelCount }) : t("settings.providers.models.other", { count: provider.modelCount })}</span><Show when={getDisconnectMode(provider) !== "disable-in-config"}><button type="button" class="selector-button selector-button-secondary providers-disconnect-button" disabled={getDisconnectMode(provider) === "not-disconnectable" || stage() !== "idle"} onClick={() => void disconnectProvider(provider.id)} title={getDisconnectMode(provider) === "not-disconnectable" ? t("settings.providers.source.env") : t("settings.providers.actions.disconnect")}>{t("settings.providers.actions.disconnect")}</button></Show></div>
</article>
)}</For>
</div>
<Show when={managedProvider()} fallback={
<>
<Show when={loading()}><div class="providers-loading-row" role="status"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
<Show when={!loading() && configuredProviders().length === 0}><div class="settings-card-message" role="status">{t("settings.providers.empty.noConfiguredProviders")}</div></Show>
<div class="providers-grid">
<For each={configuredProviders()}>{(provider) => (
<article class="providers-card">
<div class="providers-card-main"><div class="providers-card-mark"><ShieldCheck class="providers-card-mark-icon" /></div><div class="providers-card-copy"><div class="providers-card-title-row"><h4 class="providers-card-title">{provider.name || provider.id}</h4></div><p class="providers-card-meta">{provider.id}</p><p class="providers-card-methods">{methodSummary(provider.id)}</p><p class="providers-card-source">{describeProviderSource(provider)}</p></div></div>
<div class="providers-card-footer">
<span class="providers-model-count">{provider.modelCount === 1 ? t("settings.providers.models.one", { count: provider.modelCount }) : t("settings.providers.models.other", { count: provider.modelCount })}</span>
<div class="provider-model-card-actions">
<button
ref={(element) => manageModelButtons.set(provider.id, element)}
type="button"
class="selector-button selector-button-secondary"
onClick={() => {
managedProviderTriggerId = provider.id
setManagedProviderId(provider.id)
}}
>{t("settings.providers.actions.manageModels")}</button>
<Show when={getDisconnectMode(provider) !== "disable-in-config"}><button type="button" class="selector-button selector-button-secondary providers-disconnect-button" disabled={getDisconnectMode(provider) === "not-disconnectable" || stage() !== "idle"} onClick={() => void disconnectProvider(provider.id)} title={getDisconnectMode(provider) === "not-disconnectable" ? t("settings.providers.source.env") : t("settings.providers.actions.disconnect")}>{t("settings.providers.actions.disconnect")}</button></Show>
</div>
</div>
</article>
)}</For>
</div>
</>
}>
{(provider) => (
<ProviderModelVisibilityManager
providerId={provider().id}
providerName={provider().name || provider().id}
models={provider().models}
onBack={closeModelManager}
/>
)}
</Show>
</section>
</Show>
</div>

View file

@ -0,0 +1,123 @@
import { ArrowLeft } from "lucide-solid"
import { createEffect, createMemo, createSignal, For, on, onMount, Show, type Component } from "solid-js"
import { useI18n } from "../../lib/i18n"
import { useConfig } from "../../stores/preferences"
export interface ProviderVisibilityModel {
id: string
name: string
}
interface ProviderModelVisibilityManagerProps {
providerId: string
providerName: string
models: readonly ProviderVisibilityModel[]
onBack: () => void
}
const compareModel = (left: ProviderVisibilityModel, right: ProviderVisibilityModel) =>
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }) ||
left.id.localeCompare(right.id, undefined, { sensitivity: "base" })
export const ProviderModelVisibilityManager: Component<ProviderModelVisibilityManagerProps> = (props) => {
const { t } = useI18n()
const {
getProviderModelVisibilityPreference,
providerModelVisibilitySaveFailed,
setProviderModelVisibility,
} = useConfig()
const [query, setQuery] = createSignal("")
const headingId = "provider-model-visibility-heading"
let searchInput: HTMLInputElement | undefined
const models = createMemo(() => [...props.models].sort(compareModel))
const modelIds = createMemo(() => models().map((model) => model.id))
const preference = createMemo(() => getProviderModelVisibilityPreference(props.providerId))
const hiddenIds = createMemo(() => new Set(preference().hiddenModelIds))
const visibleCount = createMemo(() => modelIds().filter((id) => !hiddenIds().has(id)).length)
const filteredModels = createMemo(() => {
const value = query().trim().toLocaleLowerCase()
if (!value) return models()
return models().filter((model) =>
model.name.toLocaleLowerCase().includes(value) || model.id.toLocaleLowerCase().includes(value),
)
})
createEffect(on(() => props.providerId, () => setQuery("")))
onMount(() => queueMicrotask(() => searchInput?.focus()))
const save = (hiddenModelIds: readonly string[]) =>
void setProviderModelVisibility(props.providerId, { hiddenModelIds }).catch(() => undefined)
const toggleModel = (modelId: string, visible: boolean) => {
const current = preference().hiddenModelIds
save(visible ? current.filter((id) => id !== modelId) : [...current, modelId])
}
const hideAll = () => save([...preference().hiddenModelIds, ...modelIds()])
return (
<section class="provider-model-visibility" aria-labelledby={headingId}>
<header class="provider-model-visibility-header">
<button type="button" class="selector-button selector-button-secondary" onClick={props.onBack}>
<ArrowLeft class="w-4 h-4" />
{t("settings.providers.modelVisibility.back")}
</button>
<div>
<h4 id={headingId} class="settings-card-title">
{t("settings.providers.modelVisibility.title", { provider: props.providerName })}
</h4>
<p class="settings-card-subtitle">{t("settings.providers.modelVisibility.subtitle")}</p>
</div>
</header>
<div class="provider-model-visibility-toolbar">
<label class="sr-only" for="provider-model-visibility-search">{t("settings.providers.modelVisibility.search.label")}</label>
<input
ref={searchInput}
id="provider-model-visibility-search"
type="search"
class="providers-input"
value={query()}
onInput={(event) => setQuery(event.currentTarget.value)}
placeholder={t("settings.providers.modelVisibility.search.placeholder")}
/>
<span class="provider-model-visibility-count" role="status">
{t("settings.providers.modelVisibility.count.visible", { visible: visibleCount(), total: models().length })}
</span>
<button type="button" class="selector-button selector-button-secondary" onClick={() => save([])}>
{t("settings.providers.modelVisibility.actions.showAll")}
</button>
<button type="button" class="selector-button selector-button-secondary" onClick={hideAll}>
{t("settings.providers.modelVisibility.actions.hideAll")}
</button>
</div>
<Show when={providerModelVisibilitySaveFailed(props.providerId)}>
<p class="settings-error-message" role="alert">{t("settings.providers.modelVisibility.save.failed")}</p>
</Show>
<Show
when={filteredModels().length > 0}
fallback={<p class="settings-card-message" role="status">{t("settings.providers.modelVisibility.list.empty")}</p>}
>
<div
class="provider-model-visibility-list"
role="list"
aria-label={t("settings.providers.modelVisibility.list.label", { provider: props.providerName })}
>
<For each={filteredModels()}>{(model) => (
<label class="provider-model-visibility-item" role="listitem">
<input
type="checkbox"
checked={!hiddenIds().has(model.id)}
onChange={(event) => toggleModel(model.id, event.currentTarget.checked)}
/>
<span><strong>{model.name || model.id}</strong><small dir="ltr">{model.id}</small></span>
</label>
)}</For>
</div>
</Show>
</section>
)
}

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Modelle suchen...",
"modelSelector.none": "Keines",
"modelSelector.trigger.primary": "Modell: {model}",
"modelSelector.trigger.ariaLabel": "Modell: {model}, Anbieter: {provider}, ID: {id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Nur Favoriten umschalten",
"modelSelector.favoritesOnly.showAll": "Alle Modelle anzeigen",
"modelSelector.favorite.add": "Zu Favoriten hinzufügen",
"modelSelector.favorite.remove": "Aus Favoriten entfernen",
"modelSelector.manageProviders": "Anbieter verwalten",
"modelSelector.unavailableModel": "{model} (nicht verfügbar)",
"settings.providers.actions.manageModels": "Modelle verwalten",
"settings.providers.modelVisibility.back": "Zurück zu Anbietern",
"settings.providers.modelVisibility.title": "Modelle für {provider}",
"settings.providers.modelVisibility.subtitle": "Wählen Sie, welche Modelle CodeNomad in der Modellauswahl anzeigt.",
"settings.providers.modelVisibility.search.label": "Anbietermodelle suchen",
"settings.providers.modelVisibility.search.placeholder": "Nach Modellname oder ID suchen...",
"settings.providers.modelVisibility.count.visible": "{visible} von {total} sichtbar",
"settings.providers.modelVisibility.actions.showAll": "Alle anzeigen",
"settings.providers.modelVisibility.actions.hideAll": "Alle ausblenden",
"settings.providers.modelVisibility.list.empty": "Keine Modelle entsprechen dieser Suche.",
"settings.providers.modelVisibility.list.label": "Modelle für {provider}",
"settings.providers.modelVisibility.save.failed": "Die Modellsichtbarkeit konnte nicht gespeichert werden.",
"thinkingSelector.variant.default": "Standard",
"thinkingSelector.label": "Denken: {variant}",

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Search models...",
"modelSelector.none": "None",
"modelSelector.trigger.primary": "Model: {model}",
"modelSelector.trigger.ariaLabel": "Model: {model}, provider: {provider}, ID: {id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Toggle favorites only",
"modelSelector.favoritesOnly.showAll": "Show all models",
"modelSelector.favorite.add": "Add to favorites",
"modelSelector.favorite.remove": "Remove from favorites",
"modelSelector.manageProviders": "Manage Providers",
"modelSelector.unavailableModel": "{model} (unavailable)",
"settings.providers.actions.manageModels": "Manage models",
"settings.providers.modelVisibility.back": "Back to providers",
"settings.providers.modelVisibility.title": "Models for {provider}",
"settings.providers.modelVisibility.subtitle": "Choose which models CodeNomad shows in model selectors.",
"settings.providers.modelVisibility.search.label": "Search provider models",
"settings.providers.modelVisibility.search.placeholder": "Search by model name or ID...",
"settings.providers.modelVisibility.count.visible": "{visible} of {total} visible",
"settings.providers.modelVisibility.actions.showAll": "Show all",
"settings.providers.modelVisibility.actions.hideAll": "Hide all",
"settings.providers.modelVisibility.list.empty": "No models match this search.",
"settings.providers.modelVisibility.list.label": "Models for {provider}",
"settings.providers.modelVisibility.save.failed": "Failed to save model visibility.",
"thinkingSelector.variant.default": "Default",
"thinkingSelector.label": "Thinking: {variant}",

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Buscar modelos...",
"modelSelector.none": "Ninguno",
"modelSelector.trigger.primary": "Modelo: {model}",
"modelSelector.trigger.ariaLabel": "Modelo: {model}, proveedor: {provider}, ID: {id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Alternar solo favoritos",
"modelSelector.favoritesOnly.showAll": "Mostrar todos los modelos",
"modelSelector.favorite.add": "Agregar a favoritos",
"modelSelector.favorite.remove": "Quitar de favoritos",
"modelSelector.manageProviders": "Gestionar proveedores",
"modelSelector.unavailableModel": "{model} (no disponible)",
"settings.providers.actions.manageModels": "Gestionar modelos",
"settings.providers.modelVisibility.back": "Volver a proveedores",
"settings.providers.modelVisibility.title": "Modelos de {provider}",
"settings.providers.modelVisibility.subtitle": "Elige qué modelos muestra CodeNomad en los selectores.",
"settings.providers.modelVisibility.search.label": "Buscar modelos del proveedor",
"settings.providers.modelVisibility.search.placeholder": "Buscar por nombre o ID del modelo...",
"settings.providers.modelVisibility.count.visible": "{visible} de {total} visibles",
"settings.providers.modelVisibility.actions.showAll": "Mostrar todos",
"settings.providers.modelVisibility.actions.hideAll": "Ocultar todos",
"settings.providers.modelVisibility.list.empty": "Ningún modelo coincide con esta búsqueda.",
"settings.providers.modelVisibility.list.label": "Modelos de {provider}",
"settings.providers.modelVisibility.save.failed": "No se pudo guardar la visibilidad de los modelos.",
"thinkingSelector.variant.default": "Por defecto",
"thinkingSelector.label": "Pensamiento: {variant}",

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Rechercher des modèles...",
"modelSelector.none": "Aucun",
"modelSelector.trigger.primary": "Modèle : {model}",
"modelSelector.trigger.ariaLabel": "Modèle : {model}, fournisseur : {provider}, identifiant : {id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Basculer en favoris uniquement",
"modelSelector.favoritesOnly.showAll": "Afficher tous les modèles",
"modelSelector.favorite.add": "Ajouter aux favoris",
"modelSelector.favorite.remove": "Retirer des favoris",
"modelSelector.manageProviders": "Gérer les fournisseurs",
"modelSelector.unavailableModel": "{model} (indisponible)",
"settings.providers.actions.manageModels": "Gérer les modèles",
"settings.providers.modelVisibility.back": "Retour aux fournisseurs",
"settings.providers.modelVisibility.title": "Modèles pour {provider}",
"settings.providers.modelVisibility.subtitle": "Choisissez les modèles affichés par CodeNomad dans les sélecteurs.",
"settings.providers.modelVisibility.search.label": "Rechercher les modèles du fournisseur",
"settings.providers.modelVisibility.search.placeholder": "Rechercher par nom ou identifiant...",
"settings.providers.modelVisibility.count.visible": "{visible} sur {total} visibles",
"settings.providers.modelVisibility.actions.showAll": "Tout afficher",
"settings.providers.modelVisibility.actions.hideAll": "Tout masquer",
"settings.providers.modelVisibility.list.empty": "Aucun modèle ne correspond à cette recherche.",
"settings.providers.modelVisibility.list.label": "Modèles pour {provider}",
"settings.providers.modelVisibility.save.failed": "Impossible denregistrer la visibilité des modèles.",
"thinkingSelector.variant.default": "Par défaut",
"thinkingSelector.label": "Réflexion : {variant}",

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "חפש מודלים...",
"modelSelector.none": "ללא",
"modelSelector.trigger.primary": "מודל: {model}",
"modelSelector.trigger.ariaLabel": "מודל: {model}, ספק: {provider}, מזהה: {id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "הצג מועדפים בלבד",
"modelSelector.favoritesOnly.showAll": "הצג את כל המודלים",
"modelSelector.favorite.add": "הוסף למועדפים",
"modelSelector.favorite.remove": "הסר ממועדפים",
"modelSelector.manageProviders": "נהל ספקים",
"modelSelector.unavailableModel": "{model} (לא זמין)",
"settings.providers.actions.manageModels": "ניהול מודלים",
"settings.providers.modelVisibility.back": "חזרה לספקים",
"settings.providers.modelVisibility.title": "מודלים עבור {provider}",
"settings.providers.modelVisibility.subtitle": "בחר אילו מודלים CodeNomad יציג בבוררי המודלים.",
"settings.providers.modelVisibility.search.label": "חיפוש מודלים של הספק",
"settings.providers.modelVisibility.search.placeholder": "חיפוש לפי שם או מזהה מודל...",
"settings.providers.modelVisibility.count.visible": "{visible} מתוך {total} גלויים",
"settings.providers.modelVisibility.actions.showAll": "הצג הכול",
"settings.providers.modelVisibility.actions.hideAll": "הסתר הכול",
"settings.providers.modelVisibility.list.empty": "אין מודלים התואמים לחיפוש.",
"settings.providers.modelVisibility.list.label": "מודלים עבור {provider}",
"settings.providers.modelVisibility.save.failed": "שמירת נראות המודלים נכשלה.",
"thinkingSelector.variant.default": "ברירת מחדל",
"thinkingSelector.label": "חשיבה: {variant}",

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "モデルを検索...",
"modelSelector.none": "なし",
"modelSelector.trigger.primary": "モデル: {model}",
"modelSelector.trigger.ariaLabel": "モデル: {model}、プロバイダー: {provider}、ID: {id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "お気に入りのみ",
"modelSelector.favoritesOnly.showAll": "すべてのモデルを表示",
"modelSelector.favorite.add": "お気に入りに追加",
"modelSelector.favorite.remove": "お気に入りから削除",
"modelSelector.manageProviders": "プロバイダーを管理",
"modelSelector.unavailableModel": "{model}(利用不可)",
"settings.providers.actions.manageModels": "モデルを管理",
"settings.providers.modelVisibility.back": "プロバイダーに戻る",
"settings.providers.modelVisibility.title": "{provider} のモデル",
"settings.providers.modelVisibility.subtitle": "CodeNomad のモデル選択に表示するモデルを選びます。",
"settings.providers.modelVisibility.search.label": "プロバイダーのモデルを検索",
"settings.providers.modelVisibility.search.placeholder": "モデル名または ID で検索...",
"settings.providers.modelVisibility.count.visible": "{total} 件中 {visible} 件を表示",
"settings.providers.modelVisibility.actions.showAll": "すべて表示",
"settings.providers.modelVisibility.actions.hideAll": "すべて非表示",
"settings.providers.modelVisibility.list.empty": "検索に一致するモデルはありません。",
"settings.providers.modelVisibility.list.label": "{provider} のモデル",
"settings.providers.modelVisibility.save.failed": "モデルの表示設定を保存できませんでした。",
"thinkingSelector.variant.default": "デフォルト",
"thinkingSelector.label": "思考: {variant}",

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "मोडेलहरू खोज्नुहोस्...",
"modelSelector.none": "कुनै छैन",
"modelSelector.trigger.primary": "मोडेल: {model}",
"modelSelector.trigger.ariaLabel": "मोडेल: {model}, प्रदायक: {provider}, आईडी: {id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "मनपर्ने मात्र टगल गर्नुहोस्",
"modelSelector.favoritesOnly.showAll": "सबै मोडेलहरू देखाउनुहोस्",
"modelSelector.favorite.add": "मनपर्नेमा थप्नुहोस्",
"modelSelector.favorite.remove": "मनपर्नेबाट हटाउनुहोस्",
"modelSelector.manageProviders": "प्रदायकहरू व्यवस्थापन गर्नुहोस्",
"modelSelector.unavailableModel": "{model} (उपलब्ध छैन)",
"settings.providers.actions.manageModels": "मोडेलहरू व्यवस्थापन गर्नुहोस्",
"settings.providers.modelVisibility.back": "प्रदायकहरूमा फर्कनुहोस्",
"settings.providers.modelVisibility.title": "{provider} का मोडेलहरू",
"settings.providers.modelVisibility.subtitle": "CodeNomad का मोडेल चयनकर्तामा देखिने मोडेलहरू छान्नुहोस्।",
"settings.providers.modelVisibility.search.label": "प्रदायकका मोडेलहरू खोज्नुहोस्",
"settings.providers.modelVisibility.search.placeholder": "मोडेल नाम वा ID बाट खोज्नुहोस्...",
"settings.providers.modelVisibility.count.visible": "{total} मध्ये {visible} देखिने",
"settings.providers.modelVisibility.actions.showAll": "सबै देखाउनुहोस्",
"settings.providers.modelVisibility.actions.hideAll": "सबै लुकाउनुहोस्",
"settings.providers.modelVisibility.list.empty": "यो खोजसँग मिल्ने मोडेल छैन।",
"settings.providers.modelVisibility.list.label": "{provider} का मोडेलहरू",
"settings.providers.modelVisibility.save.failed": "मोडेल दृश्यता सुरक्षित गर्न असफल भयो।",
"thinkingSelector.variant.default": "पूर्वनिर्धारित",
"thinkingSelector.label": "सोचाइ: {variant}",

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Поиск моделей…",
"modelSelector.none": "Нет",
"modelSelector.trigger.primary": "Модель: {model}",
"modelSelector.trigger.ariaLabel": "Модель: {model}, провайдер: {provider}, ID: {id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Только избранное",
"modelSelector.favoritesOnly.showAll": "Показать все модели",
"modelSelector.favorite.add": "Добавить в избранное",
"modelSelector.favorite.remove": "Удалить из избранного",
"modelSelector.manageProviders": "Управление провайдерами",
"modelSelector.unavailableModel": "{model} (недоступна)",
"settings.providers.actions.manageModels": "Управление моделями",
"settings.providers.modelVisibility.back": "Назад к провайдерам",
"settings.providers.modelVisibility.title": "Модели {provider}",
"settings.providers.modelVisibility.subtitle": "Выберите модели, которые CodeNomad показывает в списках выбора.",
"settings.providers.modelVisibility.search.label": "Поиск моделей провайдера",
"settings.providers.modelVisibility.search.placeholder": "Поиск по имени или ID модели...",
"settings.providers.modelVisibility.count.visible": "Видно {visible} из {total}",
"settings.providers.modelVisibility.actions.showAll": "Показать все",
"settings.providers.modelVisibility.actions.hideAll": "Скрыть все",
"settings.providers.modelVisibility.list.empty": "Нет моделей, соответствующих поиску.",
"settings.providers.modelVisibility.list.label": "Модели {provider}",
"settings.providers.modelVisibility.save.failed": "Не удалось сохранить видимость моделей.",
"thinkingSelector.variant.default": "По умолчанию",
"thinkingSelector.label": "Размышления: {variant}",

View file

@ -28,11 +28,25 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "搜索模型...",
"modelSelector.none": "无",
"modelSelector.trigger.primary": "模型:{model}",
"modelSelector.trigger.ariaLabel": "模型:{model},提供商:{provider}ID{id}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "仅显示收藏",
"modelSelector.favoritesOnly.showAll": "显示所有模型",
"modelSelector.favorite.add": "添加到收藏",
"modelSelector.favorite.remove": "从收藏移除",
"modelSelector.manageProviders": "管理提供商",
"modelSelector.unavailableModel": "{model}(不可用)",
"settings.providers.actions.manageModels": "管理模型",
"settings.providers.modelVisibility.back": "返回提供商列表",
"settings.providers.modelVisibility.title": "{provider} 的模型",
"settings.providers.modelVisibility.subtitle": "选择 CodeNomad 在模型选择器中显示的模型。",
"settings.providers.modelVisibility.search.label": "搜索提供商模型",
"settings.providers.modelVisibility.search.placeholder": "按模型名称或 ID 搜索...",
"settings.providers.modelVisibility.count.visible": "显示 {visible}/{total}",
"settings.providers.modelVisibility.actions.showAll": "全部显示",
"settings.providers.modelVisibility.actions.hideAll": "全部隐藏",
"settings.providers.modelVisibility.list.empty": "没有与搜索匹配的模型。",
"settings.providers.modelVisibility.list.label": "{provider} 的模型",
"settings.providers.modelVisibility.save.failed": "无法保存模型可见性设置。",
"thinkingSelector.variant.default": "默认",
"thinkingSelector.label": "思考:{variant}",

View file

@ -0,0 +1,76 @@
import assert from "node:assert/strict"
import fs from "node:fs"
import test from "node:test"
import {
isModelVisible,
normalizeModelVisibilityPreference,
normalizeModelVisibilityPreferences,
resolvePickerValue,
} from "./model-visibility"
test("normalizes malformed preferences fail-open", () => {
assert.deepEqual(normalizeModelVisibilityPreference(null), { hiddenModelIds: [] })
assert.deepEqual(normalizeModelVisibilityPreference({ mode: "custom", modelIds: ["legacy"] }), { hiddenModelIds: [] })
assert.deepEqual(normalizeModelVisibilityPreference({ hiddenModelIds: "hidden" }), { hiddenModelIds: [] })
assert.deepEqual(normalizeModelVisibilityPreferences([]), {})
assert.deepEqual(normalizeModelVisibilityPreferences({ broken: { hiddenModelIds: null } }), {
broken: { hiddenModelIds: [] },
})
})
test("keeps unique non-empty exact IDs without catalog-based deletion", () => {
assert.deepEqual(
normalizeModelVisibilityPreference({ hiddenModelIds: ["Model-A", "model-a", "", "Model-A", 7] }),
{ hiddenModelIds: ["Model-A", "model-a"] },
)
})
test("matches hidden IDs exactly and case-sensitively", () => {
const preference = { hiddenModelIds: ["Model-A"] }
assert.equal(isModelVisible(preference, "Model-A"), false)
assert.equal(isModelVisible(preference, "model-a"), true)
})
test("new models and missing preferences are visible by default", () => {
assert.equal(isModelVisible({ hiddenModelIds: ["known"] }, "new"), true)
assert.equal(isModelVisible(undefined, "new"), true)
})
test("a current model receives no helper-level visibility override", () => {
assert.equal(isModelVisible({ hiddenModelIds: ["current"] }, "current"), false)
})
test("uses the current model as the picker value only while it remains in the collection", () => {
const current = { key: "provider/current" }
assert.equal(resolvePickerValue(current, [current]), current)
assert.equal(resolvePickerValue(current, [{ key: "provider/visible" }]), undefined)
})
test("provider refresh updates shared and modal catalogs without disposing active instances", () => {
const source = fs.readFileSync(
new URL("../components/provider-auth/provider-manager-modal.tsx", import.meta.url),
"utf8",
)
const start = source.indexOf("async function refreshProviderData()")
const end = source.indexOf("function closeModelManager()", start)
const refresh = source.slice(start, end)
assert.ok(start >= 0 && end > start)
assert.match(refresh, /await fetchProviders\(instanceId\)/)
assert.match(refresh, /await loadProviderData\(authClient\)/)
assert.doesNotMatch(refresh, /global\.dispose/)
})
test("model picker wires collection-safe selection and an accessible current-model label", () => {
const source = fs.readFileSync(new URL("../components/model-selector.tsx", import.meta.url), "utf8")
assert.match(source, /value=\{comboboxValue\(\)\}/)
assert.equal(source.match(/aria-label=\{currentModelAccessibleLabel\(\)\}/g)?.length, 2)
assert.match(source, /id: `\$\{current\.providerId\}\/\$\{current\.id\}`/)
})
test("serializes visibility writes across providers before installing server snapshots", () => {
const source = fs.readFileSync(new URL("../stores/preferences.tsx", import.meta.url), "utf8")
assert.match(source, /let modelVisibilityWriteQueue = Promise\.resolve\(\)/)
assert.match(source, /const previous = modelVisibilityWriteQueue/)
assert.match(source, /modelVisibilityWriteQueue = write/)
})

View file

@ -0,0 +1,39 @@
export type ModelVisibilityPreference = { hiddenModelIds: readonly string[] }
export type ModelVisibilityPreferences = Record<string, ModelVisibilityPreference>
export function normalizeModelVisibilityPreference(value: unknown): ModelVisibilityPreference {
if (!value || typeof value !== "object" || Array.isArray(value)) return { hiddenModelIds: [] }
const hiddenModelIds = (value as { hiddenModelIds?: unknown }).hiddenModelIds
if (!Array.isArray(hiddenModelIds)) return { hiddenModelIds: [] }
return {
hiddenModelIds: Array.from(new Set(hiddenModelIds.filter(
(id): id is string => typeof id === "string" && id.length > 0,
))),
}
}
export function normalizeModelVisibilityPreferences(value: unknown): ModelVisibilityPreferences {
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
const result: ModelVisibilityPreferences = {}
for (const [providerId, preference] of Object.entries(value as Record<string, unknown>)) {
if (!providerId) continue
result[providerId] = normalizeModelVisibilityPreference(preference)
}
return result
}
export function isModelVisible(
preference: ModelVisibilityPreference | undefined,
modelId: string,
): boolean {
return !normalizeModelVisibilityPreference(preference).hiddenModelIds.includes(modelId)
}
export function resolvePickerValue<T extends { key: string }>(
current: T | undefined,
options: readonly { key: string }[],
): T | undefined {
return current && options.some((option) => option.key === current.key) ? current : undefined
}

View file

@ -14,6 +14,12 @@ import {
import { getLogger } from "../lib/logger"
import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech"
import { buildSpeechPatch } from "../lib/speech-patch"
import {
normalizeModelVisibilityPreference,
normalizeModelVisibilityPreferences,
type ModelVisibilityPreference,
type ModelVisibilityPreferences,
} from "../lib/model-visibility"
const log = getLogger("actions")
@ -103,6 +109,7 @@ export interface UiSettings {
usageMetricsExpansion: ExpansionPreference
autoCleanupBlankSessions: boolean
keepUnseenSubagentIdleStatus: boolean
modelVisibility: ModelVisibilityPreferences
// OS notifications
osNotificationsEnabled: boolean
@ -193,6 +200,7 @@ const defaultUiSettings: UiSettings = {
usageMetricsExpansion: "collapsed",
autoCleanupBlankSessions: true,
keepUnseenSubagentIdleStatus: false,
modelVisibility: {},
osNotificationsEnabled: false,
osNotificationsAllowWhenVisible: false,
@ -299,6 +307,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions,
keepUnseenSubagentIdleStatus:
sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus,
modelVisibility: normalizeModelVisibilityPreferences(sanitized.modelVisibility),
osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled,
osNotificationsAllowWhenVisible:
sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible,
@ -616,7 +625,11 @@ async function patchStateOwner(owner: string, patch: unknown) {
function updateUiSettings(updates: Partial<UiSettings>) {
const current = uiConfigBucket()
const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates })
const patch = { settings: nextSettings }
const patch = {
settings: Object.fromEntries(
Object.keys(updates).map((key) => [key, nextSettings[key as keyof UiSettings]]),
),
}
void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error))
}
@ -624,6 +637,67 @@ function updatePreferences(updates: Partial<UiSettings>): void {
updateUiSettings(updates)
}
const modelVisibilityWriteQueues = new Map<string, Promise<void>>()
let modelVisibilityWriteQueue = Promise.resolve()
const [pendingModelVisibility, setPendingModelVisibility] = createSignal(new Map<string, ModelVisibilityPreference>())
const [modelVisibilityWriteFailures, setModelVisibilityWriteFailures] = createSignal(new Set<string>())
function getProviderModelVisibilityPreference(providerId: string): ModelVisibilityPreference {
return pendingModelVisibility().get(providerId)
?? normalizeModelVisibilityPreference(preferences().modelVisibility[providerId])
}
function providerModelVisibilitySaveFailed(providerId: string): boolean {
return modelVisibilityWriteFailures().has(providerId)
}
async function setProviderModelVisibility(providerId: string, preference: ModelVisibilityPreference): Promise<void> {
if (!providerId) return
const normalized = normalizeModelVisibilityPreference(preference)
setPendingModelVisibility((current) => new Map(current).set(providerId, normalized))
setModelVisibilityWriteFailures((current) => {
const next = new Set(current)
next.delete(providerId)
return next
})
const previous = modelVisibilityWriteQueue
const write = previous
.catch(() => undefined)
.then(() => patchConfigOwner("ui", {
settings: {
modelVisibility: {
[providerId]: normalized,
},
},
}))
modelVisibilityWriteQueue = write
modelVisibilityWriteQueues.set(providerId, write)
void write.then(
() => {
if (modelVisibilityWriteQueues.get(providerId) !== write) return
modelVisibilityWriteQueues.delete(providerId)
setPendingModelVisibility((current) => {
const next = new Map(current)
next.delete(providerId)
return next
})
},
(error) => {
log.error("Failed to update provider model visibility", error)
if (modelVisibilityWriteQueues.get(providerId) !== write) return
modelVisibilityWriteQueues.delete(providerId)
setPendingModelVisibility((current) => {
const next = new Map(current)
next.delete(providerId)
return next
})
setModelVisibilityWriteFailures((current) => new Set(current).add(providerId))
},
)
await write
}
function setThemePreference(preference: ThemePreference): void {
if (themePreference() === preference) return
void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error))
@ -945,6 +1019,9 @@ interface ConfigContextValue {
useTauriNativeEventTransport: typeof useTauriNativeEventTransport
setUseTauriNativeEventTransport: typeof setUseTauriNativeEventTransport
updatePreferences: typeof updatePreferences
setProviderModelVisibility: typeof setProviderModelVisibility
getProviderModelVisibilityPreference: typeof getProviderModelVisibilityPreference
providerModelVisibilitySaveFailed: typeof providerModelVisibilitySaveFailed
themePreference: typeof themePreference
setThemePreference: typeof setThemePreference
@ -1008,6 +1085,9 @@ const configContextValue: ConfigContextValue = {
useTauriNativeEventTransport,
setUseTauriNativeEventTransport,
updatePreferences,
setProviderModelVisibility,
getProviderModelVisibilityPreference,
providerModelVisibilitySaveFailed,
themePreference,
setThemePreference,
serverSettings,
@ -1105,6 +1185,9 @@ export {
themePreference,
setThemePreference,
updatePreferences,
setProviderModelVisibility,
getProviderModelVisibilityPreference,
providerModelVisibilitySaveFailed,
setListeningMode,
updateEnvironmentVariables,
addEnvironmentVariable,

View file

@ -90,7 +90,7 @@
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
gap: 0.875rem;
}
@ -211,6 +211,11 @@
border-top: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent);
}
.providers-card-footer .selector-button {
flex-shrink: 0;
white-space: nowrap;
}
.providers-disconnect-button {
width: min(9rem, 50%);
min-width: 6.5rem;

View file

@ -0,0 +1,89 @@
.provider-model-visibility {
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-model-visibility-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-base);
}
.provider-model-visibility-header > .selector-button {
flex: 0 0 auto;
width: auto;
}
.provider-model-visibility-item span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.2rem;
}
.provider-model-visibility-item small {
color: var(--text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
overflow-wrap: anywhere;
}
.provider-model-visibility-toolbar {
display: grid;
grid-template-columns: minmax(10rem, 1fr) auto auto auto;
align-items: center;
gap: 0.5rem;
}
.provider-model-visibility-count {
color: var(--text-secondary);
font-size: var(--font-size-xs);
}
.provider-model-visibility-list {
height: min(22rem, 45dvh);
overflow-y: auto;
border: 1px solid var(--border-base);
background: var(--surface-base);
}
.provider-model-visibility-item {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 3.375rem;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent);
color: var(--text-primary);
cursor: pointer;
}
.provider-model-visibility-item:hover {
background: var(--surface-secondary);
}
.provider-model-card-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 700px) {
.provider-model-visibility-toolbar {
grid-template-columns: 1fr;
}
.provider-model-visibility-header,
.provider-model-card-actions {
align-items: stretch;
flex-direction: column;
}
.provider-model-visibility-toolbar .selector-button {
width: 100%;
justify-content: center;
}
}

View file

@ -14,3 +14,4 @@
@import "./components/settings-info.css";
@import "./components/config-files-settings.css";
@import "./components/provider-auth.css";
@import "./components/provider-model-visibility.css";