diff --git a/package.json b/package.json index 509ace5..5cc156f 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,11 @@ { "command": "truthlog.openOutput", "title": "TruthLog: Open Output" + }, + { + "command": "truthlog.copyAsMarkdown", + "title": "TruthLog: Copy as Markdown", + "icon": "$(copy)" } ], "menus": { @@ -66,6 +71,17 @@ { "command": "truthlog.openOutput", "when": "false" + }, + { + "command": "truthlog.copyAsMarkdown", + "when": "false" + } + ], + "view/item/context": [ + { + "command": "truthlog.copyAsMarkdown", + "when": "view == truthlog.history", + "group": "1_copy" } ], "view/title": [ diff --git a/src/extension.ts b/src/extension.ts index 15836d6..c9cd1e7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -231,6 +231,27 @@ function renderOutputDocument(execution: Execution): string { return `${header}\n${execution.output}`; } +/** + * Wraps renderOutputDocument()'s existing header+output formatting in a + * fenced code block, so pasting it into a chat with an AI agent (the + * motivating use case: pasting back the ground truth to correct a false + * claim) renders as a single readable block instead of losing its layout to + * markdown reflow. + */ +function renderAsMarkdown(execution: Execution): string { + return '```\n' + renderOutputDocument(execution) + '\n```'; +} + +function executionIdFromCommandArg(arg: unknown): string | undefined { + if (typeof arg === 'string') { + return arg; + } + if (typeof arg === 'object' && arg !== null && typeof (arg as { id?: unknown }).id === 'string') { + return (arg as { id: string }).id; + } + return undefined; +} + export function activate(context: vscode.ExtensionContext): TruthLogTestApi { outputChannel = vscode.window.createOutputChannel('TruthLog'); context.subscriptions.push(outputChannel); @@ -269,6 +290,25 @@ export function activate(context: vscode.ExtensionContext): TruthLogTestApi { }) ); + context.subscriptions.push( + vscode.commands.registerCommand('truthlog.copyAsMarkdown', async (arg: unknown) => { + const id = executionIdFromCommandArg(arg); + if (id === undefined) { + log(`WARNING truthlog.copyAsMarkdown called without a valid execution: ${JSON.stringify(arg)}`); + return; + } + const execution = store.getById(id); + if (!execution) { + log(`WARNING truthlog.copyAsMarkdown: execution "${id}" is no longer in history.`); + void vscode.window.showWarningMessage( + 'TruthLog: that execution is no longer in history (it was cleared or trimmed).' + ); + return; + } + await vscode.env.clipboard.writeText(renderAsMarkdown(execution)); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand('truthlog.clear', () => { store.clear(); diff --git a/src/test/suite/recording.test.ts b/src/test/suite/recording.test.ts index cfced25..0c840b9 100644 --- a/src/test/suite/recording.test.ts +++ b/src/test/suite/recording.test.ts @@ -240,6 +240,43 @@ describe('TruthLog recording pipeline (steps 4-9)', function () { } }); + it('step 7b: truthlog.copyAsMarkdown copies command, exit code, and output as a fenced block', async () => { + const marker = 'TRUTHLOG_COPY_AS_MARKDOWN_MARKER'; + const execution = await runAndWaitForRecord( + api.store, + shellIntegration, + `node -e "console.log('${marker}')"` + ); + + // Real invocations come from the tree's right-click context menu, which + // VS Code invokes with the whole selected Execution element (not just + // its id) as the command argument - unlike truthlog.openOutput, which is + // wired to a manually-specified id via item.command.arguments. + await vscode.commands.executeCommand('truthlog.copyAsMarkdown', execution); + + const clipboardText = await vscode.env.clipboard.readText(); + console.log('RAW clipboard text after truthlog.copyAsMarkdown:', JSON.stringify(clipboardText)); + + assert.ok(clipboardText.startsWith('```\n'), 'expected the copied text to be a fenced markdown code block'); + assert.ok(clipboardText.trim().endsWith('```'), 'expected the fenced code block to be closed'); + assert.ok(clipboardText.includes(execution.command), 'expected the copied text to include the command'); + assert.ok(clipboardText.includes('exit code: 0'), 'expected the copied text to include the exit code'); + assert.ok(clipboardText.includes(marker), 'expected the copied text to include the raw output'); + }); + + it('step 7c: truthlog.copyAsMarkdown also accepts a bare execution id', async () => { + const execution = await runAndWaitForRecord(api.store, shellIntegration, 'echo copy-by-id'); + await vscode.env.clipboard.writeText(''); // reset so a stale value can't produce a false pass + + await vscode.commands.executeCommand('truthlog.copyAsMarkdown', execution.id); + + const clipboardText = await vscode.env.clipboard.readText(); + assert.ok( + clipboardText.includes('echo copy-by-id'), + `expected copying by bare id to copy the right execution, got: ${JSON.stringify(clipboardText)}` + ); + }); + it('step 8: truthlog.toggleFailuresOnly filters the tree to non-zero exits', async () => { const pass = await runAndWaitForRecord(api.store, shellIntegration, 'echo filter-pass'); const fail = await runAndWaitForRecord(api.store, shellIntegration, 'node -e "process.exit(2)"');