Skip to content
Open
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
21 changes: 13 additions & 8 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
};
Expand Down
73 changes: 73 additions & 0 deletions src/renderer/export-session.ts
Original file line number Diff line number Diff line change
@@ -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<Entry, { kind: 'tool' }>): string {
if (entry.running) return 'Running';
return entry.ok ? 'Done' : 'Failed';
}

function toolHeading(entry: Extract<Entry, { kind: 'tool' }>): 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`;
}
155 changes: 155 additions & 0 deletions tests/unit/export-session.test.ts
Original file line number Diff line number Diff line change
@@ -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<Entry, { kind: 'tool' }> = {
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',
);
});
});
Loading