fix: show model display name in message footer and transcript (#20539)
parent
b1c07488bd
commit
c526caae7b
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue