Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions packages/opencode/test/config/tui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,22 @@ it.instance("missing tui.json - silently treated as empty (ENOENT path)", () =>
const config = yield* getTuiConfig(test.directory)
expect(config).toBeDefined()
expect(config.theme).toBeUndefined()
expect(config.model_picker.group_search_results).toBe(false)
}),
),
)

it.instance("resolves model_picker.group_search_results from tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
model_picker: { group_search_results: true },
})

const config = yield* getTuiConfig(test.directory)
expect(config.model_picker.group_search_results).toBe(true)
}),
),
)
258 changes: 148 additions & 110 deletions packages/tui/src/component/dialog-model.tsx
Original file line number Diff line number Diff line change
@@ -1,132 +1,42 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "../context/local"
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useSync } from "../context/sync"
import { useTuiConfig } from "../config"
import type { Provider } from "@opencode-ai/sdk/v2"

type ModelReference = { providerID: string; modelID: string }

type ModelPickerOption = DialogSelectOption<ModelReference | string> & { key?: ModelReference }

export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const [query, setQuery] = createSignal("")
const tuiConfig = useTuiConfig()

const connected = useConnected()
const providers = createDialogProviderOptions()

const showExtra = createMemo(() => connected() && !props.providerID)

const options = createMemo(() => {
const needle = query().trim()
const showSections = showExtra() && needle.length === 0
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent()

function toOptions(items: typeof favorites, category: string) {
if (!showSections) return []
return items.flatMap((item) => {
const provider = sync.data.provider.find((provider) => provider.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
onSelect(provider.id, model.id)
},
},
]
})
}

const favoriteOptions = toOptions(favorites, "Favorites")
const recentOptions = toOptions(
recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
),
"Recent",
)

const providerOptions = pipe(
sync.data.provider,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => ({
value: { providerID: provider.id, modelID: model },
title: info.name ?? model,
releaseDate: info.release_date,
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
onSelect(provider.id, model)
},
})),
filter((option) => {
if (!showSections) return true
if (
favorites.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
)
return false
if (
recents.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
)
return false
return true
}),
(options) => sortModelOptions(options, props.providerID !== undefined),
),
),
)

const popularProviders = !connected()
? pipe(
providers(),
map((option) => ({
...option,
category: "Popular providers",
})),
take(6),
)
: []

if (needle) {
return [
...sortModelOptions(
fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
false,
),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
}

return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
return createModelPickerOptions({
query: query(),
connected: connected(),
providerID: props.providerID,
groupSearchResults: tuiConfig.model_picker.group_search_results,
providers: sync.data.provider,
favorites: connected() ? local.model.favorite() : [],
recents: local.model.recent(),
popularProviders: providers(),
onSelect,
})
})

const provider = createMemo(() =>
Expand Down Expand Up @@ -175,14 +85,142 @@ export function DialogModel(props: { providerID?: string }) {
},
]}
onFilter={setQuery}
flat={true}
flat={!tuiConfig.model_picker.group_search_results}
skipFilter={true}
title={title()}
current={local.model.current()}
/>
)
}

export function createModelPickerOptions(input: {
query: string
connected: boolean
providerID?: string
groupSearchResults: boolean
providers: Provider[]
favorites: ModelReference[]
recents: ModelReference[]
popularProviders: ModelPickerOption[]
onSelect: (providerID: string, modelID: string) => void
}) {
const needle = input.query.trim()
const showSections = input.connected && !input.providerID && (needle.length === 0 || input.groupSearchResults)

function toOptions(items: ModelReference[], category: string): ModelPickerOption[] {
if (!showSections) return []
return items.flatMap((item) => {
const provider = input.providers.find((provider) => provider.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
input.onSelect(provider.id, model.id)
},
},
]
})
}

const favoriteOptions = toOptions(input.favorites, "Favorites")
const recentOptions = toOptions(
input.recents.filter(
(item) => !input.favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
),
"Recent",
)

const providerOptions = pipe(
input.providers,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (input.providerID ? info.providerID === input.providerID : true)),
map(([model, info]) => ({
value: { providerID: provider.id, modelID: model },
title: info.name ?? model,
releaseDate: info.release_date,
description: input.favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: input.connected ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
input.onSelect(provider.id, model)
},
})),
filter((option) => {
if (!showSections) return true
if (
input.favorites.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
)
return false
if (
input.recents.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
)
return false
return true
}),
(options) => sortModelOptions(options, input.providerID !== undefined),
),
),
)

const popularProviders = !input.connected
? pipe(
input.popularProviders,
map((option) => ({
...option,
category: "Popular providers",
})),
take(6),
)
: []

if (needle) {
if (input.groupSearchResults) {
return [
...fuzzysort.go(needle, favoriteOptions, { keys: ["title", "description"] }).map((x) => x.obj),
...fuzzysort.go(needle, recentOptions, { keys: ["title", "description"] }).map((x) => x.obj),
...sortModelOptions(
fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
false,
),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
}
return [
...sortModelOptions(
fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
false,
),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
}

return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
}

export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
options: T[],
newestFirst: boolean,
Expand Down
15 changes: 14 additions & 1 deletion packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export const Prompt = Schema.Struct({
}),
}).annotate({ description: "Prompt size settings" })

export const ModelPicker = Schema.Struct({
group_search_results: Schema.optional(Schema.Boolean).annotate({
description: "Keep Favorites, Recent, and provider groups while searching models",
}),
}).annotate({ description: "Model picker settings" })

export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String),
theme: Schema.optional(Schema.String),
Expand All @@ -63,10 +69,11 @@ export const Info = Schema.Struct({
scroll_acceleration: Schema.optional(ScrollAcceleration),
diff_style: Schema.optional(DiffStyle),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
model_picker: Schema.optional(ModelPicker),
})
export type Info = Schema.Schema.Type<typeof Info>

export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse"> & {
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse" | "model_picker"> & {
attention: {
enabled: boolean
notifications: boolean
Expand All @@ -78,6 +85,9 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" |
keybinds: TuiKeybind.BindingLookupView
leader_timeout: number
mouse: boolean
model_picker: {
group_search_results: boolean
}
}

export const ResolveOptions = Schema.Struct({
Expand Down Expand Up @@ -113,6 +123,9 @@ export function resolve(input: Info, options: ResolveOptions): Resolved {
}),
leader_timeout: input.leader_timeout ?? LeaderTimeoutDefault,
mouse: input.mouse ?? true,
model_picker: {
group_search_results: input.model_picker?.group_search_results ?? false,
},
}
}

Expand Down
Loading
Loading