Compare commits

...

1 Commits

Author SHA1 Message Date
David Hill 6b6a37e7ff fix(app): replace free model name label with badge 2026-07-13 13:59:38 +00:00
8 changed files with 176 additions and 52 deletions
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/FreeModelBadge"
const projectID = "proj_free_model_badge"
const sessionID = "ses_free_model_badge"
test("shows the Free badge instead of repeating Free in an OpenCode model name", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "free-model-badge",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"glm-5-free": {
id: "glm-5-free",
name: "GLM 5 Free",
cost: { input: 0 },
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "glm-5-free" },
},
sessions: [
{
id: sessionID,
slug: "free-model-badge",
projectID,
directory,
title: "Free model badge",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="session-composer"]')
await expectAppVisible(composer)
const trigger = composer.locator('[data-action="prompt-model"]')
await expect(trigger).toContainText("GLM 5")
await expect(trigger).toContainText("Free")
await expect(trigger).not.toContainText("GLM 5 Free")
await trigger.click()
const option = page.getByRole("dialog").getByRole("button", { name: /GLM 5/ })
await expect(option).toBeVisible()
await expect(option).toContainText("GLM 5")
await expect(option).toContainText("Free")
await expect(option).not.toContainText("GLM 5 Free")
await page.screenshot({ path: "e2e/test-results/free-model-badge.png" })
})
@@ -11,6 +11,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
import { isFreeModel, modelDisplayName } from "./model-display"
type ModelState = ReturnType<typeof useLocal>["model"]
@@ -26,9 +27,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
const c = model.current()
return c ? `${c.provider.id}:${c.id}` : undefined
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
@@ -83,15 +81,15 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
placement="right-start"
gutter={6}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item)}>
<span class="min-w-0 truncate">{modelDisplayName(item)}</span>
<Show when={isFreeModel(item)}>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={item.latest}>
@@ -11,6 +11,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
import { isFreeModel, modelDisplayName } from "./model-display"
type ModelState = ReturnType<typeof useLocal>["model"]
@@ -57,13 +58,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
class="w-full"
placement="right-start"
gutter={12}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} />}
>
{node}
</Tooltip>
@@ -77,7 +72,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<span>{modelDisplayName(i)}</span>
<Tag>{language.t("model.tag.free")}</Tag>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
@@ -31,9 +31,7 @@ import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
import { isFreeModel, modelDisplayName } from "./model-display"
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelItem = ReturnType<ModelState["list"]>[number]
@@ -94,7 +92,7 @@ const ModelList: Component<{
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} />}
>
{node}
</Tooltip>
@@ -108,8 +106,8 @@ const ModelList: Component<{
>
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}>
<span class="truncate">{modelDisplayName(i)}</span>
<Show when={isFreeModel(i)}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<Show when={i.latest}>
@@ -453,14 +451,7 @@ export function ModelSelectorPopoverV2(props: {
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
>
<MenuV2.RadioItem
value={modelKey(item)}
@@ -474,8 +465,8 @@ export function ModelSelectorPopoverV2(props: {
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<span class="min-w-0 truncate leading-5">{modelDisplayName(item)}</span>
<Show when={isFreeModel(item)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { isFreeModel, modelDisplayName } from "./model-display"
const model = (name: string, provider = "opencode", input = 0) => ({
name,
provider: { id: provider },
cost: { input },
})
describe("model display", () => {
test("recognizes free models provided by OpenCode", () => {
expect(isFreeModel(model("GLM 5 Free"))).toBe(true)
expect(isFreeModel(model("GLM 5 Free", "openrouter"))).toBe(false)
expect(isFreeModel(model("GLM 5 Free", "opencode", 1))).toBe(false)
})
test("removes the standalone free label from free model names", () => {
expect(modelDisplayName(model("GLM 5 Free"))).toBe("GLM 5")
expect(modelDisplayName(model("GLM 5 (Free)"))).toBe("GLM 5")
expect(modelDisplayName(model("Free GLM 5"))).toBe("GLM 5")
})
test("preserves names that are not OpenCode free models", () => {
expect(modelDisplayName(model("GLM 5 Free", "openrouter"))).toBe("GLM 5 Free")
expect(modelDisplayName(model("FreeModel"))).toBe("FreeModel")
})
})
@@ -0,0 +1,17 @@
type ModelDisplay = {
name: string
provider: { id: string }
cost?: { input: number }
}
export const isFreeModel = (model: ModelDisplay) =>
model.provider.id === "opencode" && (!model.cost || model.cost.input === 0)
export const modelDisplayName = (model: ModelDisplay) => {
if (!isFreeModel(model)) return model.name
return model.name
.replace(/\(\s*free\s*\)/gi, " ")
.replace(/\bfree\b/gi, " ")
.replace(/\s+/g, " ")
.trim()
}
@@ -1,5 +1,6 @@
import { Show, type Component, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { modelDisplayName } from "./model-display"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
type InputMap = Record<InputKey, boolean>
@@ -8,8 +9,12 @@ type ModelInfo = {
id: string
name: string
provider: {
id: string
name: string
}
cost?: {
input: number
}
capabilities?: {
reasoning: boolean
input: InputMap
@@ -60,14 +65,14 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
return `${sourceName(props.model)} ${modelDisplayName(props.model)}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
return `${modelDisplayName(props.model)}${suffix}`
}
const inputs = () => {
if (props.model.capabilities) {
+38 -20
View File
@@ -41,12 +41,15 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Tag } from "@opencode-ai/ui/tag"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { isFreeModel, modelDisplayName } from "@/components/model-display"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
@@ -1541,26 +1544,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return "Ask anything, / for commands, @ for context..."
}
const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybindParts("model.choose"),
model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
newLayoutDesigns: props.controls.newLayoutDesigns,
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
if (props.controls.newLayoutDesigns) {
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
return
}
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
},
}))
const modelControlState = createMemo<ComposerModelControlState>(() => {
const current = props.controls.model.selection.current()
return {
loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybindParts("model.choose"),
model: props.controls.model.selection,
providerID: current?.provider?.id,
modelName: current ? modelDisplayName(current) : language.t("dialog.model.select.title"),
free: current ? isFreeModel(current) : false,
newLayoutDesigns: props.controls.newLayoutDesigns,
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
if (props.controls.newLayoutDesigns) {
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
return
}
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
},
}
})
const newSession = () => props.variant === "new-session"
const bindEditorRef = (el: HTMLDivElement) => {
@@ -2178,6 +2185,7 @@ type ComposerModelControlState = {
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
free: boolean
newLayoutDesigns: boolean
style: JSX.CSSProperties | undefined
onClose: () => void
@@ -2217,6 +2225,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
}
function ComposerModelControl(props: { state: ComposerModelControlState }) {
const language = useLanguage()
return (
<Show when={!props.state.loading}>
<Show
@@ -2255,6 +2264,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Show when={props.state.free}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
@@ -2330,6 +2342,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
}
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
const language = useLanguage()
return (
<>
<Show when={props.state.providerID}>
@@ -2342,6 +2355,11 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<Show when={props.state.free}>
<Show when={props.v2} fallback={<Tag>{language.t("model.tag.free")}</Tag>}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
</Show>
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>