From 04717dcfebde35364c54874f3fed0975d314e015 Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Tue, 22 Sep 2026 00:55:30 -0400 Subject: [PATCH 1/6] fix(cli): format opencode models --verbose as a table opencode models --verbose printed JSON.stringify(model, null, 2) for every model, which is unreadable once several providers are configured. It now prints one aligned table per provider, with the columns the model catalog already carries: model id, name, base cost per 1M tokens, context and output limits, and capabilities. Default output is unchanged: the same plain provider/model lines on stdout, so existing pipelines keep working. --- packages/opencode/src/cli/cmd/models.ts | 87 +++++++++++++++++++------ 1 file changed, 66 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 38ac4881ccdf..a10670c19d44 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -4,6 +4,7 @@ import { ModelsDev } from "@opencode-ai/core/models-dev" import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import { ProviderV2 } from "@opencode-ai/core/provider" +import type { Provider } from "@/provider/provider" export const ModelsCommand = effectCmd({ command: "models [provider]", @@ -33,26 +34,6 @@ export const ModelsCommand = effectCmd({ const provider = yield* Provider.Service const providers = yield* provider.list() - const print = (providerID: ProviderV2.ID, verbose?: boolean) => { - const p = providers[providerID] - const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sorted) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) - } - } - } - - if (args.provider) { - const providerID = ProviderV2.ID.make(args.provider) - if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) - print(providerID, args.verbose) - return - } - const ids = Object.keys(providers).sort((a, b) => { const aIsOpencode = a.startsWith("opencode") const bIsOpencode = b.startsWith("opencode") @@ -61,6 +42,70 @@ export const ModelsCommand = effectCmd({ return a.localeCompare(b) }) - for (const providerID of ids) print(ProviderV2.ID.make(providerID), args.verbose) + let selected = ids + if (args.provider) { + const providerID = ProviderV2.ID.make(args.provider) + if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) + selected = [providerID] + } + + const print = (providerID: ProviderV2.ID) => { + const p = providers[providerID] + const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) + if (args.verbose) { + process.stdout.write(formatProviderTable(providerID, p.name, sorted) + EOL + EOL) + return + } + for (const [modelID] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + } + } + + for (const providerID of selected) print(ProviderV2.ID.make(providerID)) }), }) + +const HEADERS = ["Model", "Name", "Cost ($/1M in/out)", "Context", "Output", "Capabilities"] + +// One table per provider, so a single long model ID cannot widen every other provider's columns. +function formatProviderTable(providerID: string, providerName: string, models: [string, Provider.Model][]): string { + const cells = models.map(([modelID, model]) => [ + `${providerID}/${modelID}`, + model.name, + formatCost(model.cost), + formatTokens(model.limit.context), + formatTokens(model.limit.output), + formatCapabilities(model.capabilities), + ]) + + const widths = HEADERS.map((header, column) => Math.max(header.length, ...cells.map((cell) => cell[column].length))) + // The last column is left unpadded so no line carries trailing whitespace. + const row = (cell: string[]) => + cell.map((value, column) => (column === cell.length - 1 ? value : value.padEnd(widths[column]))).join(" ") + + const header = row(HEADERS) + return [providerName, "─".repeat(header.length), header, "─".repeat(header.length), ...cells.map(row)].join(EOL) +} + +// A model prices a base rate plus optional context tiers; the table shows the base rate. +function formatCost(cost: Provider.Model["cost"]): string { + if (!cost) return "-" + if (cost.input === 0 && cost.output === 0) return "free" + return `${cost.input} / ${cost.output}` +} + +function formatTokens(count: number): string { + if (!count) return "-" + if (count >= 1_000_000) return `${Math.round(count / 1_000_000)}M` + if (count >= 1_000) return `${Math.round(count / 1_000)}K` + return String(count) +} + +function formatCapabilities(capabilities: Provider.Model["capabilities"]): string { + const flags: string[] = [] + if (capabilities.reasoning) flags.push("reasoning") + if (capabilities.toolcall) flags.push("tools") + if (capabilities.attachment) flags.push("attachments") + return flags.length ? flags.join(", ") : "-" +} From c3f9bcb94b1049ad500682803f392b6e6ea35de9 Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Tue, 22 Sep 2026 01:02:42 -0400 Subject: [PATCH 2/6] test(cli): cover the models --verbose table formatter Asserts column alignment, the absence of trailing whitespace, the provider-prefixed model id, cost/limit/capability rendering, and the empty-provider case. --- packages/opencode/src/cli/cmd/models.ts | 6 +- packages/opencode/test/cli/cmd/models.test.ts | 99 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/cli/cmd/models.test.ts diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index a10670c19d44..610fae440871 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -69,7 +69,11 @@ export const ModelsCommand = effectCmd({ const HEADERS = ["Model", "Name", "Cost ($/1M in/out)", "Context", "Output", "Capabilities"] // One table per provider, so a single long model ID cannot widen every other provider's columns. -function formatProviderTable(providerID: string, providerName: string, models: [string, Provider.Model][]): string { +export function formatProviderTable( + providerID: string, + providerName: string, + models: [string, Provider.Model][], +): string { const cells = models.map(([modelID, model]) => [ `${providerID}/${modelID}`, model.name, diff --git a/packages/opencode/test/cli/cmd/models.test.ts b/packages/opencode/test/cli/cmd/models.test.ts new file mode 100644 index 000000000000..96697d67f464 --- /dev/null +++ b/packages/opencode/test/cli/cmd/models.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { EOL } from "os" +import { formatProviderTable } from "../../../src/cli/cmd/models" +import type { Provider } from "../../../src/provider/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" + +const MODALITIES = { text: true, audio: false, image: false, video: false, pdf: false } + +function model(overrides: Partial = {}): Provider.Model { + return { + id: ModelV2.ID.make("model-id"), + providerID: ProviderV2.ID.make("provider-id"), + api: { id: "api", url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: false, + input: MODALITIES, + output: MODALITIES, + interleaved: false, + }, + cost: { input: 3, output: 15, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 64_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + ...overrides, + } +} + +describe("cli.models", () => { + test("aligns every column and leaves no trailing whitespace", () => { + const table = formatProviderTable("acme", "Acme", [ + ["short", model({ name: "Short" })], + ["a/much/longer/model/id", model({ name: "Longer" })], + ]) + const lines = table.split(EOL) + + for (const line of lines) expect(line).toBe(line.trimEnd()) + + // Header, rule, and every row start the Name column at the same offset. + const header = lines[2] + const nameColumn = header.indexOf("Name") + for (const line of [lines[4], lines[5]]) { + expect(line.slice(nameColumn).startsWith("Short") || line.slice(nameColumn).startsWith("Longer")).toBe(true) + } + expect(lines[0]).toBe("Acme") + expect(lines[1]).toBe("─".repeat(header.length)) + expect(lines[3]).toBe("─".repeat(header.length)) + }) + + test("prefixes model ids with the provider id", () => { + const table = formatProviderTable("acme", "Acme", [["gpt-9", model()]]) + expect(table).toContain("acme/gpt-9") + }) + + test("formats cost, limits and capabilities", () => { + const table = formatProviderTable("acme", "Acme", [ + ["priced", model({ cost: { input: 3, output: 15, cache: { read: 0, write: 0 } } })], + ["free", model({ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } } })], + ["big", model({ limit: { context: 1_000_000, output: 900 } })], + [ + "capable", + model({ + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: MODALITIES, + output: MODALITIES, + interleaved: false, + }, + }), + ], + ]) + + expect(table).toContain("3 / 15") + expect(table).toContain("free") + expect(table).toContain("200K") + expect(table).toContain("64K") + expect(table).toContain("1M") + expect(table).toContain("900") + expect(table).toContain("reasoning, tools, attachments") + // A model with no capability flags renders a placeholder rather than an empty cell. + expect(table).toContain("-") + }) + + test("renders a header-only table for a provider with no models", () => { + const table = formatProviderTable("acme", "Acme", []) + const lines = table.split(EOL) + expect(lines).toHaveLength(4) + expect(lines[2]).toContain("Capabilities") + }) +}) From ec01dc308580007a32889fd5bfa1c2dcfa3393e0 Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Tue, 22 Sep 2026 01:10:12 -0400 Subject: [PATCH 3/6] docs(cli): show the models --verbose table Documents what --verbose prints now, and states that the default output is still one plain provider/model per line so it can be piped. --- packages/web/src/content/docs/cli.mdx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 4e4fea2b46ac..3a939b3f9db3 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -334,6 +334,23 @@ Use the `--refresh` flag to update the cached model list. This is useful when ne opencode models --refresh ``` +Use the `--verbose` flag to see what each model is, as a table per provider. + +```bash +opencode models --verbose +``` + +``` +Anthropic +───────────────────────────────────────────────────────────────────────────────────────────────── +Model Name Cost ($/1M in/out) Context Output Capabilities +───────────────────────────────────────────────────────────────────────────────────────────────── +anthropic/claude-opus-4-5 Claude Opus 4.5 5 / 25 200K 64K reasoning, tools, attachments +anthropic/claude-sonnet-4-5 Claude Sonnet 4.5 3 / 15 200K 64K reasoning, tools, attachments +``` + +Costs are the base rate per 1M tokens, before any context-tier pricing. The default output stays one plain `provider/model` per line, so it can still be piped into other commands. + --- ### run From f81c2940085b0aa8f659b18c2386b1789647038a Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Tue, 22 Sep 2026 10:09:54 -0400 Subject: [PATCH 4/6] fix(cli): don't label undeclared model costs as free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom provider's model whose config declares no cost is stored with a cost of 0, so the table labelled it "free" — including paid models such as anthropic/claude-haiku-4.5 behind a custom OpenRouter provider. A zero cost now reads "free" only for opencode's own models, matching the TUI model picker's "Free" label, and "-" (unknown) everywhere else. Token limits also rounded to whole units, so a 1.5M context showed as "2M" and 999,600 as "1000K". Millions now keep one decimal. The tests assert on the cell in each row: several of the previous assertions matched text elsewhere in the table and could not fail. --- packages/opencode/src/cli/cmd/models.ts | 15 +++-- packages/opencode/test/cli/cmd/models.test.ts | 58 ++++++++++++------- packages/web/src/content/docs/cli.mdx | 2 +- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 610fae440871..6008e3eb57b2 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -77,7 +77,7 @@ export function formatProviderTable( const cells = models.map(([modelID, model]) => [ `${providerID}/${modelID}`, model.name, - formatCost(model.cost), + formatCost(providerID, model.cost), formatTokens(model.limit.context), formatTokens(model.limit.output), formatCapabilities(model.capabilities), @@ -93,19 +93,24 @@ export function formatProviderTable( } // A model prices a base rate plus optional context tiers; the table shows the base rate. -function formatCost(cost: Provider.Model["cost"]): string { - if (!cost) return "-" - if (cost.input === 0 && cost.output === 0) return "free" +// A model whose config declares no cost is stored as 0, so a zero cost only means free +// for opencode's own models, matching the "Free" label in the TUI model picker. +function formatCost(providerID: string, cost: Provider.Model["cost"]): string { + if (cost.input === 0 && cost.output === 0) return providerID === "opencode" ? "free" : "-" return `${cost.input} / ${cost.output}` } function formatTokens(count: number): string { if (!count) return "-" - if (count >= 1_000_000) return `${Math.round(count / 1_000_000)}M` + if (count >= 999_500) return `${trimDecimal(count / 1_000_000)}M` if (count >= 1_000) return `${Math.round(count / 1_000)}K` return String(count) } +function trimDecimal(value: number): string { + return value.toFixed(1).replace(/\.0$/, "") +} + function formatCapabilities(capabilities: Provider.Model["capabilities"]): string { const flags: string[] = [] if (capabilities.reasoning) flags.push("reasoning") diff --git a/packages/opencode/test/cli/cmd/models.test.ts b/packages/opencode/test/cli/cmd/models.test.ts index 96697d67f464..7585ff671103 100644 --- a/packages/opencode/test/cli/cmd/models.test.ts +++ b/packages/opencode/test/cli/cmd/models.test.ts @@ -32,6 +32,14 @@ function model(overrides: Partial = {}): Provider.Model { } } +// Returns the cells of the row for a model ID. Columns are separated by at least +// two spaces, and no fixture value contains two consecutive spaces. +function cells(table: string, id: string): string[] { + const line = table.split(EOL).find((row) => row.startsWith(id + " ")) + if (!line) throw new Error(`no row for ${id}`) + return line.split(/ {2,}/) +} + describe("cli.models", () => { test("aligns every column and leaves no trailing whitespace", () => { const table = formatProviderTable("acme", "Acme", [ @@ -61,33 +69,39 @@ describe("cli.models", () => { test("formats cost, limits and capabilities", () => { const table = formatProviderTable("acme", "Acme", [ ["priced", model({ cost: { input: 3, output: 15, cache: { read: 0, write: 0 } } })], - ["free", model({ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } } })], - ["big", model({ limit: { context: 1_000_000, output: 900 } })], + ["large", model({ limit: { context: 1_048_576, output: 900 } })], [ "capable", - model({ - capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: MODALITIES, - output: MODALITIES, - interleaved: false, - }, - }), + model({ capabilities: { ...model().capabilities, reasoning: true, attachment: true, toolcall: true } }), ], + ["plain", model()], ]) - expect(table).toContain("3 / 15") - expect(table).toContain("free") - expect(table).toContain("200K") - expect(table).toContain("64K") - expect(table).toContain("1M") - expect(table).toContain("900") - expect(table).toContain("reasoning, tools, attachments") - // A model with no capability flags renders a placeholder rather than an empty cell. - expect(table).toContain("-") + expect(cells(table, "acme/priced")).toEqual(["acme/priced", "Model", "3 / 15", "200K", "64K", "-"]) + expect(cells(table, "acme/large").slice(3, 5)).toEqual(["1M", "900"]) + expect(cells(table, "acme/capable")[5]).toBe("reasoning, tools, attachments") + expect(cells(table, "acme/plain")[5]).toBe("-") + }) + + test("only calls a zero cost free for opencode's own models", () => { + // A config model that declares no cost is stored as 0, which means unknown, not free. + const zero = model({ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } } }) + + expect(cells(formatProviderTable("opencode", "OpenCode Zen", [["zen", zero]]), "opencode/zen")[2]).toBe("free") + expect(cells(formatProviderTable("acme", "Acme", [["custom", zero]]), "acme/custom")[2]).toBe("-") + }) + + test("rounds token limits without overstating them", () => { + const limits = (context: number) => + cells(formatProviderTable("acme", "Acme", [["m", model({ limit: { context, output: 0 } })]]), "acme/m")[3] + + expect(limits(1_500_000)).toBe("1.5M") + expect(limits(2_000_000)).toBe("2M") + // Rounds up into the next unit rather than printing "1000K". + expect(limits(999_600)).toBe("1M") + expect(limits(131_072)).toBe("131K") + expect(limits(512)).toBe("512") + expect(limits(0)).toBe("-") }) test("renders a header-only table for a provider with no models", () => { diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 3a939b3f9db3..1e4b24aad96c 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -349,7 +349,7 @@ anthropic/claude-opus-4-5 Claude Opus 4.5 5 / 25 200K 64K anthropic/claude-sonnet-4-5 Claude Sonnet 4.5 3 / 15 200K 64K reasoning, tools, attachments ``` -Costs are the base rate per 1M tokens, before any context-tier pricing. The default output stays one plain `provider/model` per line, so it can still be piped into other commands. +Costs are the base rate per 1M tokens, before any context-tier pricing. A `-` means the value is not known — for example, a custom provider's model whose config declares no cost. The default output stays one plain `provider/model` per line, so it can still be piped into other commands. --- From e5759bfcbf63d257cfaf9d0eaf8e1d733a27093b Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Tue, 22 Sep 2026 10:41:15 -0400 Subject: [PATCH 5/6] fix(cli): keep the models table aligned for any model name Four models.dev names end in a tab (e.g. novita-ai "DeepSeek V3 (Turbo)"), which the terminal expands to the next tab stop, shifting those rows. A name with a newline would split its row. Cell values now collapse runs of whitespace to a single space. Columns are also padded by terminal display width (Bun.stringWidth, as the run footer already does) rather than string length, so rows with wide characters such as CJK names stay aligned. Checked against the full models.dev catalogue: 222 providers and 7,835 models render with every column aligned. --- packages/opencode/src/cli/cmd/models.ts | 38 +++++++++++++------ packages/opencode/test/cli/cmd/models.test.ts | 24 ++++++++++++ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 6008e3eb57b2..cd0b66880011 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -74,22 +74,38 @@ export function formatProviderTable( providerName: string, models: [string, Provider.Model][], ): string { - const cells = models.map(([modelID, model]) => [ - `${providerID}/${modelID}`, - model.name, - formatCost(providerID, model.cost), - formatTokens(model.limit.context), - formatTokens(model.limit.output), - formatCapabilities(model.capabilities), - ]) + const cells = models.map(([modelID, model]) => + [ + `${providerID}/${modelID}`, + model.name, + formatCost(providerID, model.cost), + formatTokens(model.limit.context), + formatTokens(model.limit.output), + formatCapabilities(model.capabilities), + ].map(singleLine), + ) - const widths = HEADERS.map((header, column) => Math.max(header.length, ...cells.map((cell) => cell[column].length))) + // Widths are measured in terminal columns, so wide characters such as CJK still line up. + const widths = HEADERS.map((header, column) => + Math.max(Bun.stringWidth(header), ...cells.map((cell) => Bun.stringWidth(cell[column]))), + ) // The last column is left unpadded so no line carries trailing whitespace. const row = (cell: string[]) => - cell.map((value, column) => (column === cell.length - 1 ? value : value.padEnd(widths[column]))).join(" ") + cell + .map((value, column) => + column === cell.length - 1 ? value : value + " ".repeat(widths[column] - Bun.stringWidth(value)), + ) + .join(" ") const header = row(HEADERS) - return [providerName, "─".repeat(header.length), header, "─".repeat(header.length), ...cells.map(row)].join(EOL) + const rule = "─".repeat(Bun.stringWidth(header)) + return [singleLine(providerName), rule, header, rule, ...cells.map(row)].join(EOL) +} + +// Catalogue names can carry tabs or newlines (some models.dev names end in a tab), +// which would shift a row or split it in two. +function singleLine(value: string): string { + return value.replace(/\s+/g, " ").trim() } // A model prices a base rate plus optional context tiers; the table shows the base rate. diff --git a/packages/opencode/test/cli/cmd/models.test.ts b/packages/opencode/test/cli/cmd/models.test.ts index 7585ff671103..0dde9b81d4fe 100644 --- a/packages/opencode/test/cli/cmd/models.test.ts +++ b/packages/opencode/test/cli/cmd/models.test.ts @@ -104,6 +104,30 @@ describe("cli.models", () => { expect(limits(0)).toBe("-") }) + test("keeps rows aligned when names carry tabs, newlines or wide characters", () => { + const table = formatProviderTable("acme", "Acme", [ + // Verbatim from models.dev: this name ends in a tab. + ["tabbed", model({ name: "DeepSeek V3 (Turbo)\t" })], + ["split", model({ name: "two\nlines" })], + ["wide", model({ name: "通义千问" })], + ["plain", model({ name: "Plain" })], + ]) + const lines = table.split(EOL) + + expect(table).not.toMatch(/\t/) + // Two header rules, the header, the provider name, and one line per model. + expect(lines).toHaveLength(8) + expect(cells(table, "acme/tabbed")[1]).toBe("DeepSeek V3 (Turbo)") + expect(cells(table, "acme/split")[1]).toBe("two lines") + + // The cost column starts at the same terminal column on every row, CJK included. + const costColumn = (id: string) => { + const line = lines.find((row) => row.startsWith(id + " "))! + return Bun.stringWidth(line.slice(0, line.indexOf("3 / 15"))) + } + expect(new Set(["acme/tabbed", "acme/split", "acme/wide", "acme/plain"].map(costColumn)).size).toBe(1) + }) + test("renders a header-only table for a provider with no models", () => { const table = formatProviderTable("acme", "Acme", []) const lines = table.split(EOL) From af00836294adac28c9c18a0b54b38486ed865aca Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Tue, 22 Sep 2026 10:48:19 -0400 Subject: [PATCH 6/6] fix(cli): extend the models table rules to the widest row The rules under the provider name and header were sized to the header, so they stopped short whenever a row's last column (e.g. "reasoning, tools, attachments") was wider than "Capabilities". They now span the widest line. --- packages/opencode/src/cli/cmd/models.ts | 6 ++++-- packages/opencode/test/cli/cmd/models.test.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index cd0b66880011..83410aeaa7ff 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -98,8 +98,10 @@ export function formatProviderTable( .join(" ") const header = row(HEADERS) - const rule = "─".repeat(Bun.stringWidth(header)) - return [singleLine(providerName), rule, header, rule, ...cells.map(row)].join(EOL) + const lines = cells.map(row) + // The rules span the widest line, which can be a row whose last column outgrows its header. + const rule = "─".repeat(Math.max(...[header, ...lines].map((line) => Bun.stringWidth(line)))) + return [singleLine(providerName), rule, header, rule, ...lines].join(EOL) } // Catalogue names can carry tabs or newlines (some models.dev names end in a tab), diff --git a/packages/opencode/test/cli/cmd/models.test.ts b/packages/opencode/test/cli/cmd/models.test.ts index 0dde9b81d4fe..75adb61ae491 100644 --- a/packages/opencode/test/cli/cmd/models.test.ts +++ b/packages/opencode/test/cli/cmd/models.test.ts @@ -61,6 +61,22 @@ describe("cli.models", () => { expect(lines[3]).toBe("─".repeat(header.length)) }) + test("extends the rules to rows whose last column is wider than its header", () => { + const table = formatProviderTable("acme", "Acme", [ + [ + "capable", + model({ capabilities: { ...model().capabilities, reasoning: true, attachment: true, toolcall: true } }), + ], + ]) + const lines = table.split(EOL) + const widest = Math.max(...lines.slice(2).map((line) => line.length)) + + // "reasoning, tools, attachments" is longer than "Capabilities". + expect(widest).toBeGreaterThan(lines[2].length) + expect(lines[1]).toBe("─".repeat(widest)) + expect(lines[3]).toBe("─".repeat(widest)) + }) + test("prefixes model ids with the provider id", () => { const table = formatProviderTable("acme", "Acme", [["gpt-9", model()]]) expect(table).toContain("acme/gpt-9")