Skip to content

Commit 05eee67

Browse files
authored
feat: add assistant metadata to session export (anomalyco#6611)
1 parent 154c52c commit 05eee67

4 files changed

Lines changed: 498 additions & 94 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 43 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { usePromptRef } from "../../context/prompt"
6868
import { Filesystem } from "@/util/filesystem"
6969
import { PermissionPrompt } from "./permission"
7070
import { DialogExportOptions } from "../../ui/dialog-export-options"
71+
import { formatTranscript } from "../../util/transcript"
7172

7273
addDefaultParsers(parsers.parsers)
7374

@@ -134,6 +135,7 @@ export function Session() {
134135
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
135136
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
136137
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
138+
const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true))
137139
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
138140
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
139141
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
@@ -712,47 +714,17 @@ export function Session() {
712714
category: "Session",
713715
onSelect: async (dialog) => {
714716
try {
715-
// Format session transcript as markdown
716717
const sessionData = session()
717718
const sessionMessages = messages()
718-
719-
let transcript = `# ${sessionData.title}\n\n`
720-
transcript += `**Session ID:** ${sessionData.id}\n`
721-
transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
722-
transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
723-
transcript += `---\n\n`
724-
725-
for (const msg of sessionMessages) {
726-
const parts = sync.data.part[msg.id] ?? []
727-
const role = msg.role === "user" ? "User" : "Assistant"
728-
transcript += `## ${role}\n\n`
729-
730-
for (const part of parts) {
731-
if (part.type === "text" && !part.synthetic) {
732-
transcript += `${part.text}\n\n`
733-
} else if (part.type === "reasoning") {
734-
if (showThinking()) {
735-
transcript += `_Thinking:_\n\n${part.text}\n\n`
736-
}
737-
} else if (part.type === "tool") {
738-
transcript += `\`\`\`\nTool: ${part.tool}\n`
739-
if (showDetails() && part.state.input) {
740-
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
741-
}
742-
if (showDetails() && part.state.status === "completed" && part.state.output) {
743-
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
744-
}
745-
if (showDetails() && part.state.status === "error" && part.state.error) {
746-
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
747-
}
748-
transcript += `\n\`\`\`\n\n`
749-
}
750-
}
751-
752-
transcript += `---\n\n`
753-
}
754-
755-
// Copy to clipboard
719+
const transcript = formatTranscript(
720+
sessionData,
721+
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
722+
{
723+
thinking: showThinking(),
724+
toolDetails: showDetails(),
725+
assistantMetadata: showAssistantMetadata(),
726+
},
727+
)
756728
await Clipboard.copy(transcript)
757729
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
758730
} catch (error) {
@@ -762,75 +734,56 @@ export function Session() {
762734
},
763735
},
764736
{
765-
title: "Export session transcript to file",
737+
title: "Export session transcript",
766738
value: "session.export",
767739
keybind: "session_export",
768740
category: "Session",
769741
onSelect: async (dialog) => {
770742
try {
771-
// Format session transcript as markdown
772743
const sessionData = session()
773744
const sessionMessages = messages()
774745

775746
const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
776747

777-
const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails())
748+
const options = await DialogExportOptions.show(
749+
dialog,
750+
defaultFilename,
751+
showThinking(),
752+
showDetails(),
753+
showAssistantMetadata(),
754+
false,
755+
)
778756

779757
if (options === null) return
780758

781-
const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options
782-
783-
let transcript = `# ${sessionData.title}\n\n`
784-
transcript += `**Session ID:** ${sessionData.id}\n`
785-
transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
786-
transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
787-
transcript += `---\n\n`
788-
789-
for (const msg of sessionMessages) {
790-
const parts = sync.data.part[msg.id] ?? []
791-
const role = msg.role === "user" ? "User" : "Assistant"
792-
transcript += `## ${role}\n\n`
793-
794-
for (const part of parts) {
795-
if (part.type === "text" && !part.synthetic) {
796-
transcript += `${part.text}\n\n`
797-
} else if (part.type === "reasoning") {
798-
if (includeThinking) {
799-
transcript += `_Thinking:_\n\n${part.text}\n\n`
800-
}
801-
} else if (part.type === "tool") {
802-
transcript += `\`\`\`\nTool: ${part.tool}\n`
803-
if (includeToolDetails && part.state.input) {
804-
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
805-
}
806-
if (includeToolDetails && part.state.status === "completed" && part.state.output) {
807-
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
808-
}
809-
if (includeToolDetails && part.state.status === "error" && part.state.error) {
810-
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
811-
}
812-
transcript += `\n\`\`\`\n\n`
813-
}
814-
}
759+
const transcript = formatTranscript(
760+
sessionData,
761+
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
762+
{
763+
thinking: options.thinking,
764+
toolDetails: options.toolDetails,
765+
assistantMetadata: options.assistantMetadata,
766+
},
767+
)
815768

816-
transcript += `---\n\n`
817-
}
769+
if (options.openWithoutSaving) {
770+
// Just open in editor without saving
771+
await Editor.open({ value: transcript, renderer })
772+
} else {
773+
const exportDir = process.cwd()
774+
const filename = options.filename.trim()
775+
const filepath = path.join(exportDir, filename)
818776

819-
// Save to file in current working directory
820-
const exportDir = process.cwd()
821-
const filename = customFilename.trim()
822-
const filepath = path.join(exportDir, filename)
777+
await Bun.write(filepath, transcript)
823778

824-
await Bun.write(filepath, transcript)
779+
// Open with EDITOR if available
780+
const result = await Editor.open({ value: transcript, renderer })
781+
if (result !== undefined) {
782+
await Bun.write(filepath, result)
783+
}
825784

826-
// Open with EDITOR if available
827-
const result = await Editor.open({ value: transcript, renderer })
828-
if (result !== undefined) {
829-
// User edited the file, save the changes
830-
await Bun.write(filepath, result)
785+
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
831786
}
832-
833-
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
834787
} catch (error) {
835788
toast.show({ message: "Failed to export session", variant: "error" })
836789
}

packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ export type DialogExportOptionsProps = {
99
defaultFilename: string
1010
defaultThinking: boolean
1111
defaultToolDetails: boolean
12-
onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
12+
defaultAssistantMetadata: boolean
13+
defaultOpenWithoutSaving: boolean
14+
onConfirm?: (options: {
15+
filename: string
16+
thinking: boolean
17+
toolDetails: boolean
18+
assistantMetadata: boolean
19+
openWithoutSaving: boolean
20+
}) => void
1321
onCancel?: () => void
1422
}
1523

@@ -20,7 +28,9 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
2028
const [store, setStore] = createStore({
2129
thinking: props.defaultThinking,
2230
toolDetails: props.defaultToolDetails,
23-
active: "filename" as "filename" | "thinking" | "toolDetails",
31+
assistantMetadata: props.defaultAssistantMetadata,
32+
openWithoutSaving: props.defaultOpenWithoutSaving,
33+
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
2434
})
2535

2636
useKeyboard((evt) => {
@@ -29,10 +39,18 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
2939
filename: textarea.plainText,
3040
thinking: store.thinking,
3141
toolDetails: store.toolDetails,
42+
assistantMetadata: store.assistantMetadata,
43+
openWithoutSaving: store.openWithoutSaving,
3244
})
3345
}
3446
if (evt.name === "tab") {
35-
const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
47+
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
48+
"filename",
49+
"thinking",
50+
"toolDetails",
51+
"assistantMetadata",
52+
"openWithoutSaving",
53+
]
3654
const currentIndex = order.indexOf(store.active)
3755
const nextIndex = (currentIndex + 1) % order.length
3856
setStore("active", order[nextIndex])
@@ -41,6 +59,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
4159
if (evt.name === "space") {
4260
if (store.active === "thinking") setStore("thinking", !store.thinking)
4361
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
62+
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
63+
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
4464
evt.preventDefault()
4565
}
4666
})
@@ -71,6 +91,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
7191
filename: textarea.plainText,
7292
thinking: store.thinking,
7393
toolDetails: store.toolDetails,
94+
assistantMetadata: store.assistantMetadata,
95+
openWithoutSaving: store.openWithoutSaving,
7496
})
7597
}}
7698
height={3}
@@ -108,6 +130,30 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
108130
</text>
109131
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
110132
</box>
133+
<box
134+
flexDirection="row"
135+
gap={2}
136+
paddingLeft={1}
137+
backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined}
138+
onMouseUp={() => setStore("active", "assistantMetadata")}
139+
>
140+
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.textMuted}>
141+
{store.assistantMetadata ? "[x]" : "[ ]"}
142+
</text>
143+
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.text}>Include assistant metadata</text>
144+
</box>
145+
<box
146+
flexDirection="row"
147+
gap={2}
148+
paddingLeft={1}
149+
backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined}
150+
onMouseUp={() => setStore("active", "openWithoutSaving")}
151+
>
152+
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.textMuted}>
153+
{store.openWithoutSaving ? "[x]" : "[ ]"}
154+
</text>
155+
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.text}>Open without saving</text>
156+
</box>
111157
</box>
112158
<Show when={store.active !== "filename"}>
113159
<text fg={theme.textMuted} paddingBottom={1}>
@@ -130,14 +176,24 @@ DialogExportOptions.show = (
130176
defaultFilename: string,
131177
defaultThinking: boolean,
132178
defaultToolDetails: boolean,
179+
defaultAssistantMetadata: boolean,
180+
defaultOpenWithoutSaving: boolean,
133181
) => {
134-
return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
182+
return new Promise<{
183+
filename: string
184+
thinking: boolean
185+
toolDetails: boolean
186+
assistantMetadata: boolean
187+
openWithoutSaving: boolean
188+
} | null>((resolve) => {
135189
dialog.replace(
136190
() => (
137191
<DialogExportOptions
138192
defaultFilename={defaultFilename}
139193
defaultThinking={defaultThinking}
140194
defaultToolDetails={defaultToolDetails}
195+
defaultAssistantMetadata={defaultAssistantMetadata}
196+
defaultOpenWithoutSaving={defaultOpenWithoutSaving}
141197
onConfirm={(options) => resolve(options)}
142198
onCancel={() => resolve(null)}
143199
/>

0 commit comments

Comments
 (0)