Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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>
}>()
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -814,6 +825,7 @@ export function Session() {
thinking: showThinking(),
toolDetails: showDetails(),
assistantMetadata: showAssistantMetadata(),
providers: sync.data.provider,
},
)
await Clipboard.copy(transcript)
Expand Down Expand Up @@ -858,6 +870,7 @@ export function Session() {
thinking: options.thinking,
toolDetails: options.toolDetails,
assistantMetadata: options.assistantMetadata,
providers: sync.data.provider,
},
)

Expand Down Expand Up @@ -1003,6 +1016,7 @@ export function Session() {
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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>
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/model.ts
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 20 additions & 6 deletions packages/opencode/src/cli/cmd/tui/util/transcript.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -26,27 +28,33 @@ 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`
transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
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) {
Expand All @@ -56,15 +64,21 @@ 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`
}

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 {
Expand Down
114 changes: 109 additions & 5 deletions packages/opencode/test/cli/tui/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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")
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
})
})
Expand Down Expand Up @@ -272,19 +336,59 @@ 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)

expect(result).toContain("# Test Session")
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",
Expand Down
Loading