diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5ebb5567d622..9800064a3f9c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -21,7 +21,15 @@ import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" +import type { + AssistantMessage, + Part, + Provider, + ToolPart, + UserMessage, + TextPart, + ReasoningPart, +} from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -69,6 +77,7 @@ import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" +import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" @@ -85,6 +94,7 @@ const context = createContext<{ showDetails: () => boolean showGenericToolOutput: () => boolean diffWrapMode: () => "word" | "none" + providers: () => ReadonlyMap sync: ReturnType tui: ReturnType }>() @@ -150,6 +160,7 @@ export function Session() { }) const showTimestamps = createMemo(() => timestamps() === "show") const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -814,6 +825,7 @@ export function Session() { thinking: showThinking(), toolDetails: showDetails(), assistantMetadata: showAssistantMetadata(), + providers: sync.data.provider, }, ) await Clipboard.copy(transcript) @@ -858,6 +870,7 @@ export function Session() { thinking: options.thinking, toolDetails: options.toolDetails, assistantMetadata: options.assistantMetadata, + providers: sync.data.provider, }, ) @@ -1003,6 +1016,7 @@ export function Session() { showDetails, showGenericToolOutput, diffWrapMode, + providers, sync, tui: tuiConfig, }} @@ -1287,10 +1301,12 @@ function UserMessage(props: { } function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { + const ctx = use() const local = useLocal() const { theme } = useTheme() const sync = useSync() const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) + const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID)) const final = createMemo(() => { return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) @@ -1360,7 +1376,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ▣{" "} {" "} {Locale.titlecase(props.message.mode)} - · {props.message.modelID} + · {model()} · {Locale.duration(duration())} diff --git a/packages/opencode/src/cli/cmd/tui/util/model.ts b/packages/opencode/src/cli/cmd/tui/util/model.ts new file mode 100644 index 000000000000..0af9ae1bf9a7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/model.ts @@ -0,0 +1,23 @@ +import type { Provider } from "@opencode-ai/sdk/v2" + +export function index(list: Provider[] | undefined) { + return new Map((list ?? []).map((item) => [item.id, item] as const)) +} + +export function get(list: Provider[] | ReadonlyMap | undefined, providerID: string, modelID: string) { + const provider = + list instanceof Map + ? list.get(providerID) + : Array.isArray(list) + ? list.find((item) => item.id === providerID) + : undefined + return provider?.models[modelID] +} + +export function name( + list: Provider[] | ReadonlyMap | undefined, + providerID: string, + modelID: string, +) { + return get(list, providerID, modelID)?.name ?? modelID +} diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts index 420c9dde1bf6..a89559c953cf 100644 --- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts +++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts @@ -1,10 +1,12 @@ -import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "@/util/locale" +import * as Model from "./model" export type TranscriptOptions = { thinking: boolean toolDetails: boolean assistantMetadata: boolean + providers?: Provider[] } export type SessionInfo = { @@ -26,6 +28,7 @@ export function formatTranscript( messages: MessageWithParts[], options: TranscriptOptions, ): string { + const providers = Model.index(options.providers) let transcript = `# ${session.title}\n\n` transcript += `**Session ID:** ${session.id}\n` transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n` @@ -33,20 +36,25 @@ export function formatTranscript( transcript += `---\n\n` for (const msg of messages) { - transcript += formatMessage(msg.info, msg.parts, options) + transcript += formatMessage(msg.info, msg.parts, options, providers) transcript += `---\n\n` } return transcript } -export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[], options: TranscriptOptions): string { +export function formatMessage( + msg: UserMessage | AssistantMessage, + parts: Part[], + options: TranscriptOptions, + providers?: Provider[] | ReadonlyMap, +): string { let result = "" if (msg.role === "user") { result += `## User\n\n` } else { - result += formatAssistantHeader(msg, options.assistantMetadata) + result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers) } for (const part of parts) { @@ -56,7 +64,11 @@ export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[] return result } -export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: boolean): string { +export function formatAssistantHeader( + msg: AssistantMessage, + includeMetadata: boolean, + providers?: Provider[] | ReadonlyMap, +): string { if (!includeMetadata) { return `## Assistant\n\n` } @@ -64,7 +76,9 @@ export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: bo const duration = msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : "" - return `## Assistant (${Locale.titlecase(msg.agent)} · ${msg.modelID}${duration ? ` · ${duration}` : ""})\n\n` + const modelName = Model.name(providers, msg.providerID, msg.modelID) + + return `## Assistant (${Locale.titlecase(msg.agent)} · ${modelName}${duration ? ` · ${duration}` : ""})\n\n` } export function formatPart(part: Part, options: TranscriptOptions): string { diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts index 7a5fa6b8f1cf..712f9112ea6d 100644 --- a/packages/opencode/test/cli/tui/transcript.test.ts +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -5,7 +5,66 @@ import { formatPart, formatTranscript, } from "../../../src/cli/cmd/tui/util/transcript" -import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2" + +const providers: Provider[] = [ + { + id: "anthropic", + name: "Anthropic", + source: "api", + env: [], + options: {}, + models: { + "claude-sonnet-4-20250514": { + id: "claude-sonnet-4-20250514", + providerID: "anthropic", + api: { + id: "claude-sonnet-4-20250514", + url: "https://example.com/claude-sonnet-4-20250514", + npm: "@ai-sdk/anthropic", + }, + name: "Claude Sonnet 4", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: true, + video: false, + pdf: true, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context: 200_000, + output: 8_192, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-05-14", + }, + }, + }, +] describe("transcript", () => { describe("formatAssistantHeader", () => { @@ -29,6 +88,11 @@ describe("transcript", () => { expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n") }) + test("uses model display name when available", () => { + const result = formatAssistantHeader(baseMsg, true, providers) + expect(result).toBe("## Assistant (Build · Claude Sonnet 4 · 5.4s)\n\n") + }) + test("excludes metadata when disabled", () => { const result = formatAssistantHeader(baseMsg, false) expect(result).toBe("## Assistant\n\n") @@ -196,7 +260,7 @@ describe("transcript", () => { }) describe("formatMessage", () => { - const options = { thinking: true, toolDetails: true, assistantMetadata: true } + const options = { thinking: true, toolDetails: true, assistantMetadata: true, providers } test("formats user message", () => { const msg: UserMessage = { @@ -230,7 +294,7 @@ describe("transcript", () => { } const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }] const result = formatMessage(msg, parts, options) - expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)") + expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 5.4s)") expect(result).toContain("Hi there") }) }) @@ -272,7 +336,12 @@ describe("transcript", () => { parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }], }, ] - const options = { thinking: false, toolDetails: false, assistantMetadata: true } + const options = { + thinking: false, + toolDetails: false, + assistantMetadata: true, + providers, + } const result = formatTranscript(session, messages, options) @@ -280,11 +349,46 @@ describe("transcript", () => { expect(result).toContain("**Session ID:** ses_abc123") expect(result).toContain("## User") expect(result).toContain("Hello") - expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)") + expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 0.5s)") expect(result).toContain("Hi!") expect(result).toContain("---") }) + test("falls back to raw model id when provider data is missing", () => { + const session = { + id: "ses_abc123", + title: "Test Session", + time: { created: 1000000000000, updated: 1000000001000 }, + } + const messages = [ + { + info: { + id: "msg_1", + sessionID: "ses_abc123", + role: "assistant" as const, + agent: "build", + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "", + parentID: "msg_0", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000000100, completed: 1000000000600 }, + }, + parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }], + }, + ] + + const result = formatTranscript(session, messages, { + thinking: false, + toolDetails: false, + assistantMetadata: true, + }) + + expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)") + }) + test("formats transcript without assistant metadata", () => { const session = { id: "ses_abc123",