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

pull/20558/head^2
Kit Langton 2026-04-01 20:17:38 -04:00 committed by GitHub
parent b1c07488bd
commit c526caae7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 170 additions and 13 deletions

View File

@ -21,7 +21,15 @@ import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme" import { selectedForeground, useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt" 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 { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool" import type { Tool } from "@/tool/tool"
@ -69,6 +77,7 @@ import { Global } from "@/global"
import { PermissionPrompt } from "./permission" import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question" import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript" import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts" import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
@ -85,6 +94,7 @@ const context = createContext<{
showDetails: () => boolean showDetails: () => boolean
showGenericToolOutput: () => boolean showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none" diffWrapMode: () => "word" | "none"
providers: () => ReadonlyMap<string, Provider>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig> tui: ReturnType<typeof useTuiConfig>
}>() }>()
@ -150,6 +160,7 @@ export function Session() {
}) })
const showTimestamps = createMemo(() => timestamps() === "show") const showTimestamps = createMemo(() => timestamps() === "show")
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
@ -814,6 +825,7 @@ export function Session() {
thinking: showThinking(), thinking: showThinking(),
toolDetails: showDetails(), toolDetails: showDetails(),
assistantMetadata: showAssistantMetadata(), assistantMetadata: showAssistantMetadata(),
providers: sync.data.provider,
}, },
) )
await Clipboard.copy(transcript) await Clipboard.copy(transcript)
@ -858,6 +870,7 @@ export function Session() {
thinking: options.thinking, thinking: options.thinking,
toolDetails: options.toolDetails, toolDetails: options.toolDetails,
assistantMetadata: options.assistantMetadata, assistantMetadata: options.assistantMetadata,
providers: sync.data.provider,
}, },
) )
@ -1003,6 +1016,7 @@ export function Session() {
showDetails, showDetails,
showGenericToolOutput, showGenericToolOutput,
diffWrapMode, diffWrapMode,
providers,
sync, sync,
tui: tuiConfig, tui: tuiConfig,
}} }}
@ -1287,10 +1301,12 @@ function UserMessage(props: {
} }
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
const ctx = use()
const local = useLocal() const local = useLocal()
const { theme } = useTheme() const { theme } = useTheme()
const sync = useSync() const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) 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(() => { const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) 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>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</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()}> <Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span> <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show> </Show>

View File

@ -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
}

View File

@ -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 { Locale } from "@/util/locale"
import * as Model from "./model"
export type TranscriptOptions = { export type TranscriptOptions = {
thinking: boolean thinking: boolean
toolDetails: boolean toolDetails: boolean
assistantMetadata: boolean assistantMetadata: boolean
providers?: Provider[]
} }
export type SessionInfo = { export type SessionInfo = {
@ -26,6 +28,7 @@ export function formatTranscript(
messages: MessageWithParts[], messages: MessageWithParts[],
options: TranscriptOptions, options: TranscriptOptions,
): string { ): string {
const providers = Model.index(options.providers)
let transcript = `# ${session.title}\n\n` let transcript = `# ${session.title}\n\n`
transcript += `**Session ID:** ${session.id}\n` transcript += `**Session ID:** ${session.id}\n`
transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n` transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
@ -33,20 +36,25 @@ export function formatTranscript(
transcript += `---\n\n` transcript += `---\n\n`
for (const msg of messages) { for (const msg of messages) {
transcript += formatMessage(msg.info, msg.parts, options) transcript += formatMessage(msg.info, msg.parts, options, providers)
transcript += `---\n\n` transcript += `---\n\n`
} }
return transcript 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 = "" let result = ""
if (msg.role === "user") { if (msg.role === "user") {
result += `## User\n\n` result += `## User\n\n`
} else { } else {
result += formatAssistantHeader(msg, options.assistantMetadata) result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers)
} }
for (const part of parts) { for (const part of parts) {
@ -56,7 +64,11 @@ export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[]
return result 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) { if (!includeMetadata) {
return `## Assistant\n\n` return `## Assistant\n\n`
} }
@ -64,7 +76,9 @@ export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: bo
const duration = const duration =
msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : "" 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 { export function formatPart(part: Part, options: TranscriptOptions): string {

View File

@ -5,7 +5,66 @@ import {
formatPart, formatPart,
formatTranscript, formatTranscript,
} from "../../../src/cli/cmd/tui/util/transcript" } 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("transcript", () => {
describe("formatAssistantHeader", () => { describe("formatAssistantHeader", () => {
@ -29,6 +88,11 @@ describe("transcript", () => {
expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n") 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", () => { test("excludes metadata when disabled", () => {
const result = formatAssistantHeader(baseMsg, false) const result = formatAssistantHeader(baseMsg, false)
expect(result).toBe("## Assistant\n\n") expect(result).toBe("## Assistant\n\n")
@ -196,7 +260,7 @@ describe("transcript", () => {
}) })
describe("formatMessage", () => { describe("formatMessage", () => {
const options = { thinking: true, toolDetails: true, assistantMetadata: true } const options = { thinking: true, toolDetails: true, assistantMetadata: true, providers }
test("formats user message", () => { test("formats user message", () => {
const msg: UserMessage = { 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 parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }]
const result = formatMessage(msg, parts, options) 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") 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!" }], 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) const result = formatTranscript(session, messages, options)
@ -280,11 +349,46 @@ describe("transcript", () => {
expect(result).toContain("**Session ID:** ses_abc123") expect(result).toContain("**Session ID:** ses_abc123")
expect(result).toContain("## User") expect(result).toContain("## User")
expect(result).toContain("Hello") 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("Hi!")
expect(result).toContain("---") 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", () => { test("formats transcript without assistant metadata", () => {
const session = { const session = {
id: "ses_abc123", id: "ses_abc123",