diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1dc2baa2ba1..dc619fc6693 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -14,6 +14,7 @@ import { useContext, } from "solid-js" import path from "node:path" +import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" import { createStore } from "solid-js/store" @@ -61,6 +62,7 @@ import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" import { FormPrompt } from "./form" import { DialogExportOptions } from "../../ui/dialog-export-options" +import { DialogExportResult } from "../../ui/dialog-export-result" import { sessionEpilogue } from "../../util/presentation" import { useTuiConfig } from "../../config" import { useClipboard } from "../../context/clipboard" @@ -711,7 +713,13 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), showDetails()) + const transcript = formatSessionTranscript( + sessionData, + messages(), + showThinking(), + showDetails(), + showAssistantMetadata(), + ) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -731,53 +739,61 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` const options = await DialogExportOptions.show( dialog, - defaultFilename, showThinking(), showDetails(), showAssistantMetadata(), - false, ) if (options === null) return - const transcript = formatSessionTranscript(sessionData, messages(), options.thinking, options.toolDetails) + const content = + options.format === "markdown" + ? formatSessionTranscript( + sessionData, + messages(), + options.thinking, + options.toolDetails, + options.assistantMetadata, + ) + : await (async () => { + if (options.debug) { + const events: unknown[] = [] + for await (const event of sdk.api.session.log({ sessionID: sessionData.id, follow: false })) { + if (event.type !== "log.synced") events.push(event) + } + return JSON.stringify({ info: sessionData, events }, null, 2) + EOL + } - if (options.openWithoutSaving) { - // Just open in editor without saving - await openEditor({ - renderer, - value: transcript, - cwd: - (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || - project.instance.directory() || - paths.cwd, - }) - } else { - const exportDir = paths.cwd - const filename = options.filename.trim() - const filepath = path.join(exportDir, filename) + 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 + })() - 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" }) + 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 { toast.show({ message: "Failed to export session", variant: "error" }) } @@ -2746,6 +2762,7 @@ function formatSessionTranscript( messages: SessionMessageInfo[], thinking: boolean, toolDetails: boolean, + assistantMetadata: boolean, ) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] @@ -2767,7 +2784,13 @@ function formatSessionTranscript( .join("\n") 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` } diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index cab853ff2ac..914d0960c8a 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -1,38 +1,63 @@ -import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { onMount, Show } from "solid-js" +import { For, Show } from "solid-js" import { useBindings } from "../keymap" +export type ExportFormat = "markdown" | "json" + export type DialogExportOptionsProps = { - defaultFilename: string defaultThinking: boolean defaultToolDetails: boolean defaultAssistantMetadata: boolean - defaultOpenWithoutSaving: boolean onConfirm?: (options: { - filename: string + action: "copy" | "export" + format: ExportFormat + debug: boolean thinking: boolean toolDetails: boolean assistantMetadata: boolean - openWithoutSaving: boolean }) => void onCancel?: () => void } +type Active = ExportFormat | "debug" | "thinking" | "toolDetails" | "assistantMetadata" | "copy" | "export" + export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() - let textarea: TextareaRenderable const [store, setStore] = createStore({ + format: "markdown" as ExportFormat, + debug: false, thinking: props.defaultThinking, toolDetails: props.defaultToolDetails, assistantMetadata: props.defaultAssistantMetadata, - openWithoutSaving: props.defaultOpenWithoutSaving, - active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving", + active: "markdown" as Active, }) + 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(() => ({ bindings: [ { @@ -40,173 +65,144 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { desc: "Next export option", group: "Dialog", cmd: () => { - 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]) + const order: Active[] = + store.format === "markdown" + ? ["markdown", "json", "thinking", "toolDetails", "assistantMetadata", "copy", "export"] + : ["markdown", "json", "debug", "copy", "export"] + setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) }, }, - ], - })) - - useBindings(() => ({ - enabled: store.active !== "filename", - bindings: [ { - key: "space", - desc: "Toggle export option", + key: "return", + desc: "Select export option", group: "Dialog", - cmd: () => { - 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) - }, + cmd: activate, }, ], })) - onMount(() => { - dialog.setSize("medium") - setTimeout(() => { - if (!textarea || textarea.isDestroyed) return - textarea.focus() - }, 1) - textarea.gotoLineEnd() - }) + const selectFormat = (format: ExportFormat) => { + setStore("format", format) + setStore("active", format) + } + + const toggle = (option: "thinking" | "toolDetails" | "assistantMetadata") => { + setStore("active", option) + setStore(option, !store[option]) + } return ( - Export Options + Export session dialog.clear()}> esc - - - Filename: - -