Jelajahi Sumber

fix: show model display name in message footer and transcript (#20539)

Kit Langton 2 minggu lalu
induk
melakukan
c526caae7b

+ 18 - 2
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<string, Provider>
   sync: ReturnType<typeof useSync>
   tui: ReturnType<typeof useTuiConfig>
 }>()
@@ -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
                 ▣{" "}
               </span>{" "}
               <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
-              <span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
+              <span style={{ fg: theme.textMuted }}> · {model()}</span>
               <Show when={duration()}>
                 <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
               </Show>

+ 23 - 0
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<string, Provider> | 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<string, Provider> | undefined,
+  providerID: string,
+  modelID: string,
+) {
+  return get(list, providerID, modelID)?.name ?? modelID
+}

+ 20 - 6
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, Provider>,
+): 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, Provider>,
+): 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 {

+ 109 - 5
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",