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
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,16 @@ export function Autocomplete(props: {
description: "rename session",
onSelect: () => command.trigger("session.rename"),
},
{
display: "/copy",
description: "copy session transcript to clipboard",
onSelect: () => command.trigger("session.copy"),
},
{
display: "/export",
description: "export session transcript to file",
onSelect: () => command.trigger("session.export"),
},
{
display: "/timeline",
description: "jump to message",
Expand Down
102 changes: 102 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ import parsers from "../../../../../../parsers-config.ts"
import { Clipboard } from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import { Global } from "@/global"
import fs from "fs/promises"

addDefaultParsers(parsers.parsers)

Expand Down Expand Up @@ -445,6 +448,105 @@ export function Session() {
dialog.clear()
},
},
{
title: "Copy session transcript",
value: "session.copy",
keybind: "session_copy",
category: "Session",
onSelect: async (dialog) => {
try {
// Format session transcript as markdown
const sessionData = session()
const sessionMessages = messages()

let transcript = `# ${sessionData.title}\n\n`
transcript += `**Session ID:** ${sessionData.id}\n`
transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
transcript += `---\n\n`

for (const msg of sessionMessages) {
const parts = sync.data.part[msg.id] ?? []
const role = msg.role === "user" ? "User" : "Assistant"
transcript += `## ${role}\n\n`

for (const part of parts) {
if (part.type === "text" && !part.synthetic) {
transcript += `${part.text}\n\n`
} else if (part.type === "tool") {
transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n`
}
}

transcript += `---\n\n`
}

// Copy to clipboard
await Clipboard.copy(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch (error) {
toast.show({ message: "Failed to copy session transcript", variant: "error" })
}
dialog.clear()
},
},
{
title: "Export session transcript to file",
value: "session.export",
keybind: "session_export",
category: "Session",
onSelect: async (dialog) => {
try {
// Format session transcript as markdown
const sessionData = session()
const sessionMessages = messages()

let transcript = `# ${sessionData.title}\n\n`
transcript += `**Session ID:** ${sessionData.id}\n`
transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
transcript += `---\n\n`

for (const msg of sessionMessages) {
const parts = sync.data.part[msg.id] ?? []
const role = msg.role === "user" ? "User" : "Assistant"
transcript += `## ${role}\n\n`

for (const part of parts) {
if (part.type === "text" && !part.synthetic) {
transcript += `${part.text}\n\n`
} else if (part.type === "tool") {
transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n`
}
}

transcript += `---\n\n`
}

// Save to file in data directory
const exportDir = path.join(Global.Path.data, "exports")
await fs.mkdir(exportDir, { recursive: true })

const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const filename = `session-${sessionData.id.slice(0, 8)}-${timestamp}.md`
const filepath = path.join(exportDir, filename)

await Bun.write(filepath, transcript)

// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
// User edited the file, save the changes
await Bun.write(filepath, result)
}

toast.show({ message: `Session exported to ${filename}`, variant: "success" })
} catch (error) {
toast.show({ message: "Failed to export session", variant: "error" })
}
dialog.clear()
},
},
{
title: "Next child session",
value: "session.child.next",
Expand Down