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
133 changes: 43 additions & 90 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem"
import { PermissionPrompt } from "./permission"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"

addDefaultParsers(parsers.parsers)

Expand Down Expand Up @@ -134,6 +135,7 @@ export function Session() {
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true))
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
Expand Down Expand Up @@ -712,47 +714,17 @@ export function Session() {
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 === "reasoning") {
if (showThinking()) {
transcript += `_Thinking:_\n\n${part.text}\n\n`
}
} else if (part.type === "tool") {
transcript += `\`\`\`\nTool: ${part.tool}\n`
if (showDetails() && part.state.input) {
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
}
if (showDetails() && part.state.status === "completed" && part.state.output) {
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
}
if (showDetails() && part.state.status === "error" && part.state.error) {
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
}
transcript += `\n\`\`\`\n\n`
}
}

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

// Copy to clipboard
const transcript = formatTranscript(
sessionData,
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
{
thinking: showThinking(),
toolDetails: showDetails(),
assistantMetadata: showAssistantMetadata(),
},
)
await Clipboard.copy(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch (error) {
Expand All @@ -762,75 +734,56 @@ export function Session() {
},
},
{
title: "Export session transcript to file",
title: "Export session transcript",
value: "session.export",
keybind: "session_export",
category: "Session",
onSelect: async (dialog) => {
try {
// Format session transcript as markdown
const sessionData = session()
const sessionMessages = messages()

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

const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails())
const options = await DialogExportOptions.show(
dialog,
defaultFilename,
showThinking(),
showDetails(),
showAssistantMetadata(),
false,
)

if (options === null) return

const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options

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 === "reasoning") {
if (includeThinking) {
transcript += `_Thinking:_\n\n${part.text}\n\n`
}
} else if (part.type === "tool") {
transcript += `\`\`\`\nTool: ${part.tool}\n`
if (includeToolDetails && part.state.input) {
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
}
if (includeToolDetails && part.state.status === "completed" && part.state.output) {
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
}
if (includeToolDetails && part.state.status === "error" && part.state.error) {
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
}
transcript += `\n\`\`\`\n\n`
}
}
const transcript = formatTranscript(
sessionData,
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
{
thinking: options.thinking,
toolDetails: options.toolDetails,
assistantMetadata: options.assistantMetadata,
},
)

transcript += `---\n\n`
}
if (options.openWithoutSaving) {
// Just open in editor without saving
await Editor.open({ value: transcript, renderer })
} else {
const exportDir = process.cwd()
const filename = options.filename.trim()
const filepath = path.join(exportDir, filename)

// Save to file in current working directory
const exportDir = process.cwd()
const filename = customFilename.trim()
const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript)

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

// 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" })
}

toast.show({ message: `Session exported to ${filename}`, variant: "success" })
} catch (error) {
toast.show({ message: "Failed to export session", variant: "error" })
}
Expand Down
64 changes: 60 additions & 4 deletions packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ export type DialogExportOptionsProps = {
defaultFilename: string
defaultThinking: boolean
defaultToolDetails: boolean
onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
defaultAssistantMetadata: boolean
defaultOpenWithoutSaving: boolean
onConfirm?: (options: {
filename: string
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
openWithoutSaving: boolean
}) => void
onCancel?: () => void
}

Expand All @@ -20,7 +28,9 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const [store, setStore] = createStore({
thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails,
active: "filename" as "filename" | "thinking" | "toolDetails",
assistantMetadata: props.defaultAssistantMetadata,
openWithoutSaving: props.defaultOpenWithoutSaving,
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
})

useKeyboard((evt) => {
Expand All @@ -29,10 +39,18 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
})
}
if (evt.name === "tab") {
const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
"filename",
"thinking",
"toolDetails",
"assistantMetadata",
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
Expand All @@ -41,6 +59,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
if (evt.name === "space") {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
evt.preventDefault()
}
})
Expand Down Expand Up @@ -71,6 +91,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
})
}}
height={3}
Expand Down Expand Up @@ -108,6 +130,30 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
</text>
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "assistantMetadata")}
>
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.textMuted}>
{store.assistantMetadata ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.text}>Include assistant metadata</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "openWithoutSaving")}
>
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.textMuted}>
{store.openWithoutSaving ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.text}>Open without saving</text>
</box>
</box>
<Show when={store.active !== "filename"}>
<text fg={theme.textMuted} paddingBottom={1}>
Expand All @@ -130,14 +176,24 @@ DialogExportOptions.show = (
defaultFilename: string,
defaultThinking: boolean,
defaultToolDetails: boolean,
defaultAssistantMetadata: boolean,
defaultOpenWithoutSaving: boolean,
) => {
return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
return new Promise<{
filename: string
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
openWithoutSaving: boolean
} | null>((resolve) => {
dialog.replace(
() => (
<DialogExportOptions
defaultFilename={defaultFilename}
defaultThinking={defaultThinking}
defaultToolDetails={defaultToolDetails}
defaultAssistantMetadata={defaultAssistantMetadata}
defaultOpenWithoutSaving={defaultOpenWithoutSaving}
onConfirm={(options) => resolve(options)}
onCancel={() => resolve(null)}
/>
Expand Down
Loading