From 1885ffec3bedd602eaf13cf6ff94d3e8b07d5314 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:35:38 +0900 Subject: [PATCH] feat: include tool context in session history export --- src/renderer/App.tsx | 21 ++-- src/renderer/export-session.ts | 73 ++++++++++++++ tests/unit/export-session.test.ts | 155 ++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 src/renderer/export-session.ts create mode 100644 tests/unit/export-session.test.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0d07f3e..19db852 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -19,6 +19,10 @@ import { } from './components/WorkbenchViews'; import { Transcript } from './components/Transcript'; import { CommandPalette } from './components/CommandPalette'; +import { + sessionExportFilename, + sessionExportMarkdown, +} from './export-session'; import { timeAgo, useWorkbench } from './useWorkbench'; function App() { @@ -108,19 +112,20 @@ function App() { const live = b.tabs; const exportSession = () => { if (!preview) return; - const text = preview.entries - .map((e) => - e.kind === 'tool' - ? `## ${e.tool}\n\n${e.output}` - : `## ${e.kind}\n\n${e.text}`, - ) - .join('\n\n'); + const exportedAt = new Date(); + const text = sessionExportMarkdown({ + title: preview.info.title, + cwd: preview.info.cwd, + model: preview.info.model, + exportedAt, + entries: preview.entries, + }); const url = URL.createObjectURL( new Blob([text], { type: 'text/markdown' }), ); const a = document.createElement('a'); a.href = url; - a.download = 'pidex-session.md'; + a.download = sessionExportFilename(preview.info.title, exportedAt); a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; diff --git a/src/renderer/export-session.ts b/src/renderer/export-session.ts new file mode 100644 index 0000000..94d68df --- /dev/null +++ b/src/renderer/export-session.ts @@ -0,0 +1,73 @@ +import type { Entry } from './timeline'; + +export type SessionExportInput = { + title: string; + cwd: string; + model: string; + exportedAt: Date; + entries: Entry[]; +}; + +function fenced(text: string): string { + let longest = 0; + let run = 0; + for (const ch of text) { + run = ch === '`' ? run + 1 : 0; + if (run > longest) longest = run; + } + const fence = '`'.repeat(Math.max(3, longest + 1)); + return `${fence}\n${text.replace(/\n$/, '')}\n${fence}`; +} + +function toolStatus(entry: Extract): string { + if (entry.running) return 'Running'; + return entry.ok ? 'Done' : 'Failed'; +} + +function toolHeading(entry: Extract): string { + const args = + entry.args.length > 120 ? `${entry.args.slice(0, 120)}...` : entry.args; + const summary = args ? ` / ${args}` : ''; + return `## ${entry.tool}${summary} (${toolStatus(entry)})`; +} + +export function sessionExportMarkdown(input: SessionExportInput): string { + const parts: string[] = [ + `# ${input.title.trim() || 'Untitled session'}`, + `Exported from pidex at ${input.exportedAt.toISOString()}.`, + ]; + if (input.cwd.trim()) parts.push(`It ran in \`${input.cwd.trim()}\`.`); + if (input.model.trim()) parts.push(`It used \`${input.model.trim()}\`.`); + if (input.entries.length === 0) { + parts.push('No messages in this selection.'); + return `${parts.join('\n\n')}\n`; + } + for (const entry of input.entries) { + if (entry.kind === 'user') { + parts.push(`## You\n\n${entry.text}`); + } else if (entry.kind === 'assistant') { + const body: string[] = []; + if (entry.text) body.push(entry.text); + if (entry.thinking) body.push(`### Thinking\n\n${entry.thinking}`); + parts.push(`## Pi\n\n${body.join('\n\n') || 'No message text.'}`); + } else { + const inputBlock = entry.input ? fenced(entry.input) : 'No input.'; + const outputBlock = entry.output ? fenced(entry.output) : 'No output.'; + parts.push( + `${toolHeading(entry)}\n\n### Input\n\n${inputBlock}\n\n### Output\n\n${outputBlock}`, + ); + } + } + return `${parts.join('\n\n')}\n`; +} + +export function sessionExportFilename(title: string, exportedAt: Date): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/, ''); + const day = exportedAt.toISOString().slice(0, 10); + return `pidex-${slug || 'session'}-${day}.md`; +} diff --git a/tests/unit/export-session.test.ts b/tests/unit/export-session.test.ts new file mode 100644 index 0000000..7fec6f4 --- /dev/null +++ b/tests/unit/export-session.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { + sessionExportFilename, + sessionExportMarkdown, +} from '../../src/renderer/export-session'; +import type { Entry } from '../../src/renderer/timeline'; + +const exportedAt = new Date('2026-11-08T01:02:03.000Z'); + +const bashDone: Extract = { + key: 'tool-1', + kind: 'tool', + toolId: 'call_1', + tool: 'bash', + args: 'npm test', + input: 'npm test', + output: '3 passed', + running: false, + ok: true, +}; + +describe('sessionExportMarkdown', () => { + it('writes the full conversation with context and tool input', () => { + const markdown = sessionExportMarkdown({ + title: 'Fix the export', + cwd: '/repo/app', + model: 'anthropic/claude-test', + exportedAt, + entries: [ + { key: 'u1', kind: 'user', text: 'please fix it' }, + { + key: 'a1', + kind: 'assistant', + text: 'On it.', + thinking: 'check the tests first', + streaming: false, + }, + bashDone, + ], + }); + expect(markdown).toBe( + [ + '# Fix the export', + '', + 'Exported from pidex at 2026-11-08T01:02:03.000Z.', + '', + 'It ran in `/repo/app`.', + '', + 'It used `anthropic/claude-test`.', + '', + '## You', + '', + 'please fix it', + '', + '## Pi', + '', + 'On it.', + '', + '### Thinking', + '', + 'check the tests first', + '', + '## bash / npm test (Done)', + '', + '### Input', + '', + '```', + 'npm test', + '```', + '', + '### Output', + '', + '```', + '3 passed', + '```', + '', + ].join('\n'), + ); + }); + + it('marks failed and running tools with the card wording', () => { + const failed = sessionExportMarkdown({ + title: 'T', + cwd: '', + model: '', + exportedAt, + entries: [{ ...bashDone, key: 'f', ok: false, output: 'boom' }], + }); + expect(failed).toContain('## bash / npm test (Failed)'); + const running = sessionExportMarkdown({ + title: 'T', + cwd: '', + model: '', + exportedAt, + entries: [{ ...bashDone, key: 'r', running: true, output: '' }], + }); + expect(running).toContain('## bash / npm test (Running)'); + }); + + it('keeps an empty input and output readable', () => { + const markdown = sessionExportMarkdown({ + title: 'T', + cwd: '', + model: '', + exportedAt, + entries: [{ ...bashDone, key: 'e', input: '', output: '' }], + }); + expect(markdown).toContain('### Input\n\nNo input.'); + expect(markdown).toContain('### Output\n\nNo output.'); + }); + + it('widens the fence when the output holds backticks', () => { + const markdown = sessionExportMarkdown({ + title: 'T', + cwd: '', + model: '', + exportedAt, + entries: [ + { ...bashDone, key: 'b', output: 'before\n```\ncode\n```\nafter' }, + ], + }); + expect(markdown).toContain('\n````\nbefore\n```\ncode\n```\nafter\n````'); + }); + + it('handles an empty selection and a blank title', () => { + const markdown = sessionExportMarkdown({ + title: ' ', + cwd: '', + model: '', + exportedAt, + entries: [], + }); + expect(markdown).toContain('# Untitled session'); + expect(markdown).toContain('No messages in this selection.'); + expect(markdown).not.toContain('It ran in'); + expect(markdown).not.toContain('It used'); + }); +}); + +describe('sessionExportFilename', () => { + it('builds the name from the title and export day', () => { + expect(sessionExportFilename('Fix: the Bug!', exportedAt)).toBe( + 'pidex-fix-the-bug-2026-11-08.md', + ); + }); + + it('falls back when the title has no usable words', () => { + expect(sessionExportFilename('!!!', exportedAt)).toBe( + 'pidex-session-2026-11-08.md', + ); + expect(sessionExportFilename('', exportedAt)).toBe( + 'pidex-session-2026-11-08.md', + ); + }); +});