Skip to content

Commit 88703bd

Browse files
committed
feat: add session export cli command
opencode session export [sessionID] export session transcript to file Positionals: sessionID session id to export Options: -o, --output output file path -f, --format output format [string] [choices: "markdown", "json"] [default: "markdown"] Signed-off-by: Christian Stewart <[email protected]>
1 parent 6b5a0fb commit 88703bd

File tree

3 files changed

+173
-42
lines changed

3 files changed

+173
-42
lines changed

packages/opencode/src/cli/cmd/session.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { bootstrap } from "../bootstrap"
55
import { UI } from "../ui"
66
import { Locale } from "../../util/locale"
77
import { EOL } from "os"
8+
import * as prompts from "@clack/prompts"
89

910
export const SessionCommand = cmd({
1011
command: "session",
1112
describe: "manage sessions",
12-
builder: (yargs: Argv) => yargs.command(SessionListCommand).demandCommand(),
13+
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionExportCommand).demandCommand(),
1314
async handler() {},
1415
})
1516

@@ -104,3 +105,119 @@ function formatSessionJSON(sessions: Session.Info[]): string {
104105
}))
105106
return JSON.stringify(jsonData, null, 2)
106107
}
108+
109+
export const SessionExportCommand = cmd({
110+
command: "export [sessionID]",
111+
describe: "export session transcript to file",
112+
builder: (yargs: Argv) => {
113+
return yargs
114+
.positional("sessionID", {
115+
describe: "session id to export",
116+
type: "string",
117+
})
118+
.option("output", {
119+
alias: "o",
120+
describe: "output file path",
121+
type: "string",
122+
})
123+
.option("format", {
124+
alias: "f",
125+
describe: "output format",
126+
type: "string",
127+
choices: ["markdown", "json"],
128+
default: "markdown",
129+
})
130+
},
131+
handler: async (args) => {
132+
await bootstrap(process.cwd(), async () => {
133+
let sessionID = args.sessionID
134+
135+
if (!sessionID) {
136+
prompts.intro("Export session", {
137+
output: process.stderr,
138+
})
139+
140+
const sessions = []
141+
for await (const session of Session.list()) {
142+
sessions.push(session)
143+
}
144+
145+
if (sessions.length === 0) {
146+
prompts.log.error("No sessions found", {
147+
output: process.stderr,
148+
})
149+
prompts.outro("Done", {
150+
output: process.stderr,
151+
})
152+
return
153+
}
154+
155+
sessions.sort((a, b) => b.time.updated - a.time.updated)
156+
157+
const selectedSession = await prompts.autocomplete({
158+
message: "Select session to export",
159+
maxItems: 10,
160+
options: sessions.map((session) => ({
161+
label: session.title,
162+
value: session.id,
163+
hint: `${new Date(session.time.updated).toLocaleString()}${session.id.slice(-8)}`,
164+
})),
165+
output: process.stderr,
166+
})
167+
168+
if (prompts.isCancel(selectedSession)) {
169+
throw new UI.CancelledError()
170+
}
171+
172+
sessionID = selectedSession as string
173+
}
174+
175+
const sessionInfo = await Session.get(sessionID!)
176+
177+
let content: string
178+
let defaultExtension: string
179+
180+
if (args.format === "json") {
181+
const sessionMessages = await Session.messages({ sessionID: sessionID! })
182+
const exportData = {
183+
info: sessionInfo,
184+
messages: sessionMessages.map((msg) => ({
185+
info: msg.info,
186+
parts: msg.parts,
187+
})),
188+
}
189+
content = JSON.stringify(exportData, null, 2)
190+
defaultExtension = "json"
191+
} else {
192+
content = await Session.exportMarkdown({
193+
sessionID: sessionID!,
194+
includeThinking: true,
195+
includeToolDetails: true,
196+
})
197+
defaultExtension = "md"
198+
}
199+
200+
const outputPath = await (async () => {
201+
if (args.output) return args.output
202+
const defaultFilename = `session-${sessionInfo.id.slice(0, 8)}.${defaultExtension}`
203+
const filenameInput = await prompts.text({
204+
message: "Export filename",
205+
defaultValue: defaultFilename,
206+
output: process.stderr,
207+
})
208+
209+
if (prompts.isCancel(filenameInput)) {
210+
throw new UI.CancelledError()
211+
}
212+
213+
return filenameInput.trim()
214+
})()
215+
216+
await Bun.write(outputPath, content)
217+
218+
prompts.outro(`Session exported to ${outputPath}`, {
219+
output: process.stderr,
220+
})
221+
})
222+
},
223+
})

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

Lines changed: 6 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import stripAnsi from "strip-ansi"
6666
import { Footer } from "./footer.tsx"
6767
import { usePromptRef } from "../../context/prompt"
6868
import { Filesystem } from "@/util/filesystem"
69+
import { Session as SessionNamespace } from "@/session"
6970
import { DialogExportOptions } from "../../ui/dialog-export-options"
7071

7172
addDefaultParsers(parsers.parsers)
@@ -821,10 +822,7 @@ export function Session() {
821822
category: "Session",
822823
onSelect: async (dialog) => {
823824
try {
824-
// Format session transcript as markdown
825825
const sessionData = session()
826-
const sessionMessages = messages()
827-
828826
const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
829827

830828
const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails())
@@ -833,53 +831,20 @@ export function Session() {
833831

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

836-
let transcript = `# ${sessionData.title}\n\n`
837-
transcript += `**Session ID:** ${sessionData.id}\n`
838-
transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
839-
transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
840-
transcript += `---\n\n`
841-
842-
for (const msg of sessionMessages) {
843-
const parts = sync.data.part[msg.id] ?? []
844-
const role = msg.role === "user" ? "User" : "Assistant"
845-
transcript += `## ${role}\n\n`
846-
847-
for (const part of parts) {
848-
if (part.type === "text" && !part.synthetic) {
849-
transcript += `${part.text}\n\n`
850-
} else if (part.type === "reasoning") {
851-
if (includeThinking) {
852-
transcript += `_Thinking:_\n\n${part.text}\n\n`
853-
}
854-
} else if (part.type === "tool") {
855-
transcript += `\`\`\`\nTool: ${part.tool}\n`
856-
if (includeToolDetails && part.state.input) {
857-
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
858-
}
859-
if (includeToolDetails && part.state.status === "completed" && part.state.output) {
860-
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
861-
}
862-
if (includeToolDetails && part.state.status === "error" && part.state.error) {
863-
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
864-
}
865-
transcript += `\n\`\`\`\n\n`
866-
}
867-
}
868-
869-
transcript += `---\n\n`
870-
}
834+
const transcript = await SessionNamespace.exportMarkdown({
835+
sessionID: route.sessionID,
836+
includeThinking,
837+
includeToolDetails,
838+
})
871839

872-
// Save to file in current working directory
873840
const exportDir = process.cwd()
874841
const filename = customFilename.trim()
875842
const filepath = path.join(exportDir, filename)
876843

877844
await Bun.write(filepath, transcript)
878845

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

packages/opencode/src/session/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,55 @@ export namespace Session {
275275
},
276276
)
277277

278+
export const exportMarkdown = fn(
279+
z.object({
280+
sessionID: Identifier.schema("session"),
281+
includeThinking: z.boolean().optional().default(true),
282+
includeToolDetails: z.boolean().optional().default(true),
283+
}),
284+
async (input) => {
285+
const sessionInfo = await get(input.sessionID)
286+
const sessionMessages = await messages({ sessionID: input.sessionID })
287+
288+
let transcript = `# ${sessionInfo.title}\n\n`
289+
transcript += `**Session ID:** ${sessionInfo.id}\n`
290+
transcript += `**Created:** ${new Date(sessionInfo.time.created).toLocaleString()}\n`
291+
transcript += `**Updated:** ${new Date(sessionInfo.time.updated).toLocaleString()}\n\n`
292+
transcript += `---\n\n`
293+
294+
for (const msg of sessionMessages) {
295+
const role = msg.info.role === "user" ? "User" : "Assistant"
296+
transcript += `## ${role}\n\n`
297+
298+
for (const part of msg.parts) {
299+
if (part.type === "text" && !part.synthetic) {
300+
transcript += `${part.text}\n\n`
301+
} else if (part.type === "reasoning") {
302+
if (input.includeThinking) {
303+
transcript += `_Thinking:_\n\n${part.text}\n\n`
304+
}
305+
} else if (part.type === "tool") {
306+
transcript += `\`\`\`\nTool: ${part.tool}\n`
307+
if (input.includeToolDetails && part.state.input) {
308+
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
309+
}
310+
if (input.includeToolDetails && part.state.status === "completed" && part.state.output) {
311+
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
312+
}
313+
if (input.includeToolDetails && part.state.status === "error" && part.state.error) {
314+
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
315+
}
316+
transcript += `\n\`\`\`\n\n`
317+
}
318+
}
319+
320+
transcript += `---\n\n`
321+
}
322+
323+
return transcript
324+
},
325+
)
326+
278327
export async function* list() {
279328
const project = Instance.project
280329
for (const item of await Storage.list(["session", project.id])) {

0 commit comments

Comments
 (0)