feat(tui): revamp session export (#35971)

This commit is contained in:
James Long
2026-07-08 18:14:43 -04:00
committed by GitHub
parent 19f42f7102
commit 79415f625a
3 changed files with 244 additions and 169 deletions
+59 -36
View File
@@ -14,6 +14,7 @@ import {
useContext, useContext,
} from "solid-js" } from "solid-js"
import path from "node:path" import path from "node:path"
import { EOL, tmpdir } from "node:os"
import { mkdir, writeFile } from "node:fs/promises" import { mkdir, writeFile } from "node:fs/promises"
import { useRoute, useRouteData } from "../../context/route" import { useRoute, useRouteData } from "../../context/route"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -61,6 +62,7 @@ import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission" import { PermissionPrompt } from "./permission"
import { FormPrompt } from "./form" import { FormPrompt } from "./form"
import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportOptions } from "../../ui/dialog-export-options"
import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation" import { sessionEpilogue } from "../../util/presentation"
import { useTuiConfig } from "../../config" import { useTuiConfig } from "../../config"
import { useClipboard } from "../../context/clipboard" import { useClipboard } from "../../context/clipboard"
@@ -711,7 +713,13 @@ export function Session() {
try { try {
const sessionData = session() const sessionData = session()
if (!sessionData) return if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), showDetails()) const transcript = formatSessionTranscript(
sessionData,
messages(),
showThinking(),
showDetails(),
showAssistantMetadata(),
)
await clipboard.write?.(transcript) await clipboard.write?.(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch { } catch {
@@ -731,53 +739,61 @@ export function Session() {
try { try {
const sessionData = session() const sessionData = session()
if (!sessionData) return if (!sessionData) return
const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
const options = await DialogExportOptions.show( const options = await DialogExportOptions.show(
dialog, dialog,
defaultFilename,
showThinking(), showThinking(),
showDetails(), showDetails(),
showAssistantMetadata(), showAssistantMetadata(),
false,
) )
if (options === null) return if (options === null) return
const transcript = formatSessionTranscript(sessionData, messages(), options.thinking, options.toolDetails) const content =
options.format === "markdown"
if (options.openWithoutSaving) { ? formatSessionTranscript(
// Just open in editor without saving sessionData,
await openEditor({ messages(),
renderer, options.thinking,
value: transcript, options.toolDetails,
cwd: options.assistantMetadata,
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || )
project.instance.directory() || : await (async () => {
paths.cwd, if (options.debug) {
}) const events: unknown[] = []
} else { for await (const event of sdk.api.session.log({ sessionID: sessionData.id, follow: false })) {
const exportDir = paths.cwd if (event.type !== "log.synced") events.push(event)
const filename = options.filename.trim() }
const filepath = path.join(exportDir, filename) return JSON.stringify({ info: sessionData, events }, null, 2) + EOL
await writeExport(filepath, transcript)
// Open with EDITOR if available
const result = await openEditor({
renderer,
value: transcript,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
paths.cwd,
})
if (result !== undefined) {
await writeExport(filepath, result)
} }
toast.show({ message: `Session exported to ${filename}`, variant: "success" }) const messages: unknown[] = []
let cursor: string | undefined
do {
const page = await sdk.api.message.list(
cursor
? { sessionID: sessionData.id, limit: 200, cursor }
: { sessionID: sessionData.id, limit: 200, order: "asc" },
)
messages.push(...page.data)
cursor = page.data.length ? (page.cursor.next ?? undefined) : undefined
} while (cursor)
return JSON.stringify({ info: sessionData, messages }, null, 2) + EOL
})()
if (options.action === "copy") {
await clipboard.write?.(content)
dialog.clear()
toast.show({ message: "Copied to clipboard", variant: "success" })
return
} }
const filepath = path.join(
tmpdir(),
`session-${crypto.randomUUID()}.${options.format === "markdown" ? "md" : "json"}`,
)
await writeExport(filepath, content)
await DialogExportResult.show(dialog, filepath)
} catch { } catch {
toast.show({ message: "Failed to export session", variant: "error" }) toast.show({ message: "Failed to export session", variant: "error" })
} }
@@ -2746,6 +2762,7 @@ function formatSessionTranscript(
messages: SessionMessageInfo[], messages: SessionMessageInfo[],
thinking: boolean, thinking: boolean,
toolDetails: boolean, toolDetails: boolean,
assistantMetadata: boolean,
) { ) {
const body = messages.flatMap((message) => { const body = messages.flatMap((message) => {
if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "user") return [`## User\n\n${message.text}`]
@@ -2767,7 +2784,13 @@ function formatSessionTranscript(
.join("\n") .join("\n")
return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`] return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`]
}) })
return [`## Assistant\n\n${content.join("\n\n")}`] const duration = message.time.completed
? ` · ${((message.time.completed - message.time.created) / 1000).toFixed(1)}s`
: ""
const heading = assistantMetadata
? `## Assistant (${message.agent} · ${message.model.providerID}/${message.model.id}${duration})`
: "## Assistant"
return [`${heading}\n\n${content.join("\n\n")}`]
}) })
return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n` return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
} }
+122 -126
View File
@@ -1,38 +1,63 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { onMount, Show } from "solid-js" import { For, Show } from "solid-js"
import { useBindings } from "../keymap" import { useBindings } from "../keymap"
export type ExportFormat = "markdown" | "json"
export type DialogExportOptionsProps = { export type DialogExportOptionsProps = {
defaultFilename: string
defaultThinking: boolean defaultThinking: boolean
defaultToolDetails: boolean defaultToolDetails: boolean
defaultAssistantMetadata: boolean defaultAssistantMetadata: boolean
defaultOpenWithoutSaving: boolean
onConfirm?: (options: { onConfirm?: (options: {
filename: string action: "copy" | "export"
format: ExportFormat
debug: boolean
thinking: boolean thinking: boolean
toolDetails: boolean toolDetails: boolean
assistantMetadata: boolean assistantMetadata: boolean
openWithoutSaving: boolean
}) => void }) => void
onCancel?: () => void onCancel?: () => void
} }
type Active = ExportFormat | "debug" | "thinking" | "toolDetails" | "assistantMetadata" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) { export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
let textarea: TextareaRenderable
const [store, setStore] = createStore({ const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
debug: false,
thinking: props.defaultThinking, thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails, toolDetails: props.defaultToolDetails,
assistantMetadata: props.defaultAssistantMetadata, assistantMetadata: props.defaultAssistantMetadata,
openWithoutSaving: props.defaultOpenWithoutSaving, active: "markdown" as Active,
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
}) })
const confirm = (action: "copy" | "export") =>
props.onConfirm?.({
action,
format: store.format,
debug: store.debug,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
})
const activate = () => {
if (store.active === "markdown" || store.active === "json") {
setStore("format", store.active)
return
}
if (store.active === "debug") setStore("debug", !store.debug)
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 === "copy" || store.active === "export") confirm(store.active)
}
useBindings(() => ({ useBindings(() => ({
bindings: [ bindings: [
{ {
@@ -40,173 +65,144 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
desc: "Next export option", desc: "Next export option",
group: "Dialog", group: "Dialog",
cmd: () => { cmd: () => {
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ const order: Active[] =
"filename", store.format === "markdown"
"thinking", ? ["markdown", "json", "thinking", "toolDetails", "assistantMetadata", "copy", "export"]
"toolDetails", : ["markdown", "json", "debug", "copy", "export"]
"assistantMetadata", setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
}, },
}, },
],
}))
useBindings(() => ({
enabled: store.active !== "filename",
bindings: [
{ {
key: "space", key: "return",
desc: "Toggle export option", desc: "Select export option",
group: "Dialog", group: "Dialog",
cmd: () => { cmd: activate,
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)
},
}, },
], ],
})) }))
onMount(() => { const selectFormat = (format: ExportFormat) => {
dialog.setSize("medium") setStore("format", format)
setTimeout(() => { setStore("active", format)
if (!textarea || textarea.isDestroyed) return }
textarea.focus()
}, 1) const toggle = (option: "thinking" | "toolDetails" | "assistantMetadata") => {
textarea.gotoLineEnd() setStore("active", option)
}) setStore(option, !store[option])
}
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1}> <box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}> <text attributes={TextAttributes.BOLD} fg={theme.text}>
Export Options Export session
</text> </text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc esc
</text> </text>
</box> </box>
<box gap={1}> <box flexDirection="row" gap={1}>
<box> <text fg={theme.text}>Export as:</text>
<text fg={theme.text}>Filename:</text> <box flexDirection="row" gap={1}>
<For each={["markdown", "json"] as const}>
{(format) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={store.format === format ? theme.backgroundElement : undefined}
onMouseUp={() => selectFormat(format)}
>
<text fg={store.format === format ? theme.text : theme.textMuted}>
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
</text>
</box> </box>
<textarea )}
onSubmit={() => { </For>
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
})
}}
height={3}
ref={(val: TextareaRenderable) => {
textarea = val
val.traits = { status: "FILENAME" }
}}
initialValue={props.defaultFilename}
placeholder="Enter filename"
placeholderColor={theme.textMuted}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
/>
</box> </box>
</box>
<Show when={store.format === "markdown"}>
<box flexDirection="column"> <box flexDirection="column">
<For
each={
[
["thinking", "Include thinking"],
["toolDetails", "Include tool details"],
["assistantMetadata", "Include assistant metadata"],
] as const
}
>
{(item) => (
<box <box
flexDirection="row" flexDirection="row"
gap={2} gap={1}
paddingLeft={1} backgroundColor={store.active === item[0] ? theme.backgroundElement : undefined}
backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined} onMouseUp={() => toggle(item[0])}
onMouseUp={() => setStore("active", "thinking")}
> >
<text fg={store.active === "thinking" ? theme.primary : theme.textMuted}> <text fg={store.active === item[0] ? theme.primary : theme.textMuted}>
{store.thinking ? "[x]" : "[ ]"} {store[item[0]] ? "[x]" : "[ ]"}
</text> </text>
<text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text> <text fg={store.active === item[0] ? theme.primary : theme.text}>{item[1]}</text>
</box> </box>
<box )}
flexDirection="row" </For>
gap={2}
paddingLeft={1}
backgroundColor={store.active === "toolDetails" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "toolDetails")}
>
<text fg={store.active === "toolDetails" ? theme.primary : theme.textMuted}>
{store.toolDetails ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
</box> </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}>
Press <span style={{ fg: theme.text }}>space</span> to toggle, <span style={{ fg: theme.text }}>return</span>{" "}
to confirm
</text>
</Show> </Show>
<Show when={store.active === "filename"}> <Show when={store.format === "json"}>
<text fg={theme.textMuted} paddingBottom={1}> <box
Press <span style={{ fg: theme.text }}>return</span> to confirm, <span style={{ fg: theme.text }}>tab</span>{" "} flexDirection="row"
for options gap={1}
</text> backgroundColor={store.active === "debug" ? theme.backgroundElement : undefined}
onMouseUp={() => {
setStore("active", "debug")
setStore("debug", !store.debug)
}}
>
<text fg={store.active === "debug" ? theme.primary : theme.textMuted}>{store.debug ? "[x]" : "[ ]"}</text>
<text fg={store.active === "debug" ? theme.primary : theme.text}>Events (debug)</text>
</box>
</Show> </Show>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={4}
paddingRight={4}
backgroundColor={theme.backgroundElement}
onMouseUp={() => confirm("copy")}
>
<text fg={theme.text}>Copy</text>
</box>
<box
paddingLeft={4}
paddingRight={4}
backgroundColor={theme.primary}
onMouseUp={() => confirm("export")}
>
<text fg={theme.selectedListItemText}>Export</text>
</box>
</box>
</box> </box>
) )
} }
DialogExportOptions.show = ( DialogExportOptions.show = (
dialog: DialogContext, dialog: DialogContext,
defaultFilename: string,
defaultThinking: boolean, defaultThinking: boolean,
defaultToolDetails: boolean, defaultToolDetails: boolean,
defaultAssistantMetadata: boolean, defaultAssistantMetadata: boolean,
defaultOpenWithoutSaving: boolean,
) => { ) => {
return new Promise<{ return new Promise<{
filename: string action: "copy" | "export"
format: ExportFormat
debug: boolean
thinking: boolean thinking: boolean
toolDetails: boolean toolDetails: boolean
assistantMetadata: boolean assistantMetadata: boolean
openWithoutSaving: boolean
} | null>((resolve) => { } | null>((resolve) => {
dialog.replace( dialog.replace(
() => ( () => (
<DialogExportOptions <DialogExportOptions
defaultFilename={defaultFilename}
defaultThinking={defaultThinking} defaultThinking={defaultThinking}
defaultToolDetails={defaultToolDetails} defaultToolDetails={defaultToolDetails}
defaultAssistantMetadata={defaultAssistantMetadata} defaultAssistantMetadata={defaultAssistantMetadata}
defaultOpenWithoutSaving={defaultOpenWithoutSaving}
onConfirm={(options) => resolve(options)} onConfirm={(options) => resolve(options)}
onCancel={() => resolve(null)} onCancel={() => resolve(null)}
/> />
@@ -0,0 +1,56 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useBindings } from "../keymap"
import { useDialog, type DialogContext } from "./dialog"
export function DialogExportResult(props: { path: string; onClose?: () => void }) {
const dialog = useDialog()
const { theme } = useTheme()
const close = () => {
props.onClose?.()
dialog.clear()
}
useBindings(() => ({
bindings: [
{
key: "return",
desc: "Close export result",
group: "Dialog",
cmd: close,
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Session exported
</text>
<text fg={theme.textMuted} onMouseUp={close}>
esc
</text>
</box>
<box>
<text fg={theme.text}>{props.path}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.primary}
onMouseUp={close}
>
<text fg={theme.selectedListItemText}>Close</text>
</box>
</box>
</box>
)
}
DialogExportResult.show = (dialog: DialogContext, path: string) =>
new Promise<void>((resolve) => {
dialog.replace(() => <DialogExportResult path={path} onClose={resolve} />, resolve)
})