diff --git a/README.md b/README.md index 16968a7..c7f1451 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,8 @@ Every conversation is saved as a transcript in `~/.klaatai/sessions/`. ``` /sessions # list saved sessions /resume # pick up exactly where you left off -/share # export session to markdown +/export # export session to Markdown (optional path) +/share # alias for /export ``` ### Permissions @@ -340,7 +341,7 @@ This README covers the highlights. For every shell flag, slash command, config k | `/test [args]` | Run tests (auto-detects Bun/Vitest/Jest/pytest/Go/Cargo) | | `/skill ` · `/hooks` | Skills and hooks | | `/init` | Generate project rules from your stack | -| `/sessions` · `/resume ` · `/share` | Session management | +| `/sessions` · `/resume ` · `/export [path]` · `/share` | Session management | | `/mcp` | Manage MCP servers | | `/agents` | List agent personas + running background sub-agents | | `/perms` | Review tool permissions | diff --git a/src/screens/export-session.test.ts b/src/screens/export-session.test.ts new file mode 100644 index 0000000..0212d32 --- /dev/null +++ b/src/screens/export-session.test.ts @@ -0,0 +1,142 @@ +import { expect, test, describe } from "bun:test"; +import { + renderSessionMarkdown, + defaultExportPath, + resolveExportPath, + fenceFor, + type ExportMessage, +} from "./export-session"; + +describe("defaultExportPath / resolveExportPath", () => { + test("default is cwd-relative klaatai-session-.md", () => { + expect(defaultExportPath("abc-123", "/tmp/proj")).toBe("/tmp/proj/klaatai-session-abc-123.md"); + }); + + test("resolveExportPath uses default when arg missing or blank", () => { + expect(resolveExportPath("s1", undefined, "/work")).toBe("/work/klaatai-session-s1.md"); + expect(resolveExportPath("s1", " ", "/work")).toBe("/work/klaatai-session-s1.md"); + }); + + test("resolveExportPath honors an explicit path", () => { + expect(resolveExportPath("s1", "./out.md", "/work")).toBe("./out.md"); + expect(resolveExportPath("s1", "/tmp/session.md", "/work")).toBe("/tmp/session.md"); + }); +}); + +describe("fenceFor", () => { + test("uses at least triple backticks", () => { + expect(fenceFor("hello")).toEqual({ open: "```", close: "```" }); + }); + + test("lengthens fence past nested backticks in the body", () => { + expect(fenceFor("code with ``` inside")).toEqual({ open: "````", close: "````" }); + expect(fenceFor("even ```` four")).toEqual({ open: "`````", close: "`````" }); + }); + + test("keeps info string on the opening fence", () => { + expect(fenceFor("-a\n+b", "diff")).toEqual({ open: "```diff", close: "```" }); + }); +}); + +describe("renderSessionMarkdown", () => { + const base = { + sessionId: "20260720-demo", + sessionCost: 0.0123, + totalRequests: 2, + exportedAt: new Date("2026-07-20T12:00:00Z"), + }; + + test("renders user and assistant turns without system messages", () => { + const messages: ExportMessage[] = [ + { role: "system", content: "hidden rules" }, + { role: "user", content: "Fix the bug" }, + { role: "assistant", content: "I'll look into it.", tier: "code", elapsed: 1500 }, + ]; + const md = renderSessionMarkdown({ ...base, messages }); + expect(md).toContain("# KlaatAI Session — 20260720-demo"); + expect(md).toContain("*Exported: 2026-07-20T12:00:00*"); + expect(md).toContain("## You"); + expect(md).toContain("Fix the bug"); + expect(md).toContain("## Assistant"); + expect(md).toContain("tier: code"); + expect(md).toContain("I'll look into it."); + expect(md).not.toContain("hidden rules"); + expect(md).toContain("*Session cost: $0.0123 | Requests: 2*"); + }); + + test("collapses long tool output into a details block", () => { + const long = Array.from({ length: 8 }, (_, i) => `line ${i}`).join("\n"); + const messages: ExportMessage[] = [ + { role: "tool", toolName: "read_file", toolSummary: "read src/a.ts", content: long }, + ]; + const md = renderSessionMarkdown({ ...base, messages }); + expect(md).toContain("### Tool: read src/a.ts"); + expect(md).toContain("
"); + expect(md).toContain("read src/a.ts · 8 lines"); + expect(md).toContain("line 0"); + expect(md).not.toMatch(/\{\s*"role"/); // no raw JSON dumps + }); + + test("short tool output stays as a simple fence", () => { + const messages: ExportMessage[] = [ + { role: "tool", toolName: "run_command", toolSummary: "$ ls", content: "ok\n" }, + ]; + const md = renderSessionMarkdown({ ...base, messages }); + expect(md).toContain("### Tool: $ ls"); + expect(md).not.toContain("
"); + expect(md).toContain("```\nok\n```"); + }); + + test("tool output containing markdown fences uses a longer outer fence", () => { + const nested = [ + "example:", + "```ts", + "const x = 1;", + "```", + "done", + "more", + "lines", + "here", + ].join("\n"); + const messages: ExportMessage[] = [ + { role: "tool", toolName: "read_file", toolSummary: "read demo.md", content: nested }, + ]; + const md = renderSessionMarkdown({ ...base, messages }); + expect(md).toContain("````\n"); + expect(md).toContain("```ts"); + expect(md).toContain("const x = 1;"); + // Outer close is four ticks — nested triple fence must not terminate early. + expect(md).toMatch(/````\n[\s\S]*```ts[\s\S]*```\n[\s\S]*````/); + }); + + test("tool diffs render as fenced diff blocks", () => { + const messages: ExportMessage[] = [ + { + role: "tool", + toolName: "edit_file", + toolSummary: "edit foo.ts", + diffPath: "foo.ts", + content: "edited", + diff: [ + { sign: " ", text: "const x = 1;" }, + { sign: "-", text: "const y = 2;" }, + { sign: "+", text: "const y = 3;" }, + ], + }, + ]; + const md = renderSessionMarkdown({ ...base, messages }); + expect(md).toContain("### Tool: edit foo.ts foo.ts (+1 −1)"); + expect(md).toContain("```diff"); + expect(md).toContain("-const y = 2;"); + expect(md).toContain("+const y = 3;"); + }); + + test("assistant errors get an Error heading", () => { + const messages: ExportMessage[] = [ + { role: "assistant", kind: "error", content: "boom" }, + ]; + const md = renderSessionMarkdown({ ...base, messages }); + expect(md).toContain("## Error"); + expect(md).toContain("boom"); + }); +}); diff --git a/src/screens/export-session.ts b/src/screens/export-session.ts new file mode 100644 index 0000000..4793fde --- /dev/null +++ b/src/screens/export-session.ts @@ -0,0 +1,189 @@ +/** + * Render a KlaatAI session transcript to clean Markdown for /export. + * + * Pure function — unit-tested — so the REPL can stay thin. + */ + +export interface ExportDiffLine { + sign: "+" | "-" | " "; + text: string; + ln?: number; +} + +export interface ExportMessage { + role: "user" | "assistant" | "system" | "tool"; + content: string; + toolName?: string; + toolSummary?: string; + kind?: "error"; + diff?: ExportDiffLine[]; + diffPath?: string; + elapsed?: number; + model?: string; + tier?: string; +} + +export interface ExportOptions { + sessionId: string; + messages: ExportMessage[]; + sessionCost: number; + totalRequests: number; + /** Defaults to now. Injected for tests. */ + exportedAt?: Date; +} + +function summarizeTool(msg: ExportMessage): string { + if (msg.toolSummary && msg.toolSummary !== msg.toolName) return msg.toolSummary; + return msg.toolName ?? "unknown"; +} + +function diffStat(diff: ExportDiffLine[]): { add: number; del: number } { + let add = 0; + let del = 0; + for (const d of diff) { + if (d.sign === "+") add++; + else if (d.sign === "-") del++; + } + return { add, del }; +} + +/** Fence long enough that nested backticks in `body` cannot close the block early. */ +export function fenceFor(body: string, info = ""): { open: string; close: string } { + let longest = 2; // at least ``` + const re = /`+/g; + let m: RegExpExecArray | null; + while ((m = re.exec(body)) !== null) { + if (m[0].length > longest) longest = m[0].length; + } + const ticks = "`".repeat(longest + 1); + return { open: info ? `${ticks}${info}` : ticks, close: ticks }; +} + +function renderDiffBlock(msg: ExportMessage): string { + const lines: string[] = []; + const pathNote = msg.diffPath ? ` ${msg.diffPath}` : ""; + const st = msg.diff ? diffStat(msg.diff) : { add: 0, del: 0 }; + const stats = + st.add || st.del + ? ` (+${st.add} −${st.del})` + : ""; + lines.push(`### Tool: ${summarizeTool(msg)}${pathNote}${stats}`); + lines.push(""); + if (msg.diff && msg.diff.length > 0) { + const body = msg.diff.map(d => `${d.sign}${d.text}`).join("\n"); + const { open, close } = fenceFor(body, "diff"); + lines.push(open); + lines.push(body); + lines.push(close); + lines.push(""); + } + return lines.join("\n"); +} + +function renderToolBlock(msg: ExportMessage): string { + if (msg.diff && msg.diff.length > 0) return renderDiffBlock(msg); + + const label = summarizeTool(msg); + const body = msg.content.trimEnd(); + const lineCount = body ? body.split("\n").length : 0; + const oneLine = + lineCount <= 1 && body.length <= 120 + ? body.replace(/\n/g, " ").trim() + : ""; + + const lines: string[] = []; + lines.push(`### Tool: ${label}`); + lines.push(""); + + if (!body) { + lines.push("_(no output)_"); + lines.push(""); + return lines.join("\n"); + } + + if (oneLine) { + const { open, close } = fenceFor(oneLine); + lines.push(open); + lines.push(oneLine); + lines.push(close); + lines.push(""); + return lines.join("\n"); + } + + // Collapsed details for longer tool output — readable without dumping JSON. + const { open, close } = fenceFor(body); + lines.push(`
`); + lines.push(`${label} · ${lineCount} lines`); + lines.push(""); + lines.push(open); + lines.push(body); + lines.push(close); + lines.push(""); + lines.push("
"); + lines.push(""); + return lines.join("\n"); +} + +/** + * Default export path: `./klaatai-session-.md` (cwd-relative). + * An explicit path argument overrides this entirely. + */ +export function defaultExportPath(sessionId: string, cwd = process.cwd()): string { + return `${cwd.replace(/\/$/, "")}/klaatai-session-${sessionId}.md`; +} + +/** Resolve `/export [path]` — empty/undefined → default under cwd. */ +export function resolveExportPath(sessionId: string, pathArg?: string, cwd = process.cwd()): string { + const trimmed = pathArg?.trim(); + if (!trimmed) return defaultExportPath(sessionId, cwd); + // Expand a lone bare filename into cwd; leave absolute / relative paths as-is. + return trimmed; +} + +export function renderSessionMarkdown(opts: ExportOptions): string { + const when = (opts.exportedAt ?? new Date()).toISOString().slice(0, 19); + const out: string[] = [ + `# KlaatAI Session — ${opts.sessionId}`, + `*Exported: ${when}*`, + "", + ]; + + for (const m of opts.messages) { + if (m.role === "system") continue; + if (m.role === "user") { + out.push(`## You`); + out.push(""); + out.push(m.content); + out.push(""); + continue; + } + if (m.role === "assistant") { + if (m.kind === "error") { + out.push(`## Error`); + out.push(""); + out.push(m.content); + out.push(""); + continue; + } + const meta: string[] = []; + if (m.tier) meta.push(`tier: ${m.tier}`); + if (m.model) meta.push(`model: ${m.model}`); + if (m.elapsed != null) meta.push(`${(m.elapsed / 1000).toFixed(1)}s`); + out.push(`## Assistant${meta.length ? ` _( ${meta.join(" · ")} )_` : ""}`); + out.push(""); + out.push(m.content); + out.push(""); + continue; + } + if (m.role === "tool") { + out.push(renderToolBlock(m)); + } + } + + out.push("---"); + out.push( + `*Session cost: $${opts.sessionCost.toFixed(4)} | Requests: ${opts.totalRequests}*`, + ); + out.push(""); + return out.join("\n"); +} diff --git a/src/screens/repl.ts b/src/screens/repl.ts index 2f0e461..90e798d 100644 --- a/src/screens/repl.ts +++ b/src/screens/repl.ts @@ -73,6 +73,10 @@ import { COMPACTION_PROMPT, extractSummary, MAX_CONSECUTIVE_COMPACT_FAILURES } f import { compactMessagesForApi } from "../agent/compaction.js"; import { stripStrayTextToolCallArtifacts } from "../agent/text-tool-artifacts.js"; import { drawWelcomeCard } from "./welcome-card.js"; +import { + renderSessionMarkdown, + resolveExportPath, +} from "./export-session.js"; import { TIER_COSTS, VALID_TIERS, TIER_CONTEXT_WINDOW, SAFE_CONTEXT_BUDGET, TIER_COLOR_MAP, KLAATU_MODEL_MAP, formatTok, formatElapsed, @@ -694,7 +698,8 @@ export async function runREPL( { cmd: "/review", desc: "AI code review of current git diff" }, { cmd: "/rollback", desc: "Restore files from a checkpoint" }, { cmd: "/sessions", desc: "List saved sessions" }, - { cmd: "/share", desc: "Export session to markdown" }, + { cmd: "/export", desc: "Export session to Markdown [path]" }, + { cmd: "/share", desc: "Alias for /export" }, { cmd: "/skill", desc: "Invoke a saved prompt skill" }, { cmd: "/test", desc: "Run the project test suite" }, { cmd: "/theme", desc: "Show or change the UI theme" }, @@ -2050,7 +2055,8 @@ export async function runREPL( " /undo — revert files written by the last AI response (via git)", " /checkpoint [lbl] — snapshot modified files (max 10 kept)", " /rollback [id] — restore files from a checkpoint", - " /share — export current session to a markdown file", + " /export [path] — export current session to Markdown (default: ./klaatai-session-.md)", + " /share [path] — alias for /export", " /plugin list — list installed plugins in ~/.klaatai/plugins/", " /doctor — diagnostics: auth, API, MCP, tools, config", " /theme [name] — show or change the UI theme", @@ -2161,26 +2167,17 @@ export async function runREPL( return true; } - if (slash === "/share") { - const outPath = join(homedir(), `klaatai-session-${sessionId}.md`); - const mdLines: string[] = [ - `# KlaatAI Session — ${sessionId}`, - `*Exported: ${new Date().toISOString().slice(0, 19)}*`, - "", - ]; - for (const m of messages) { - if (m.role === "system") continue; - if (m.role === "user") { - mdLines.push(`## You\n\n${m.content}\n`); - } else if (m.role === "assistant" && m.kind !== "error") { - mdLines.push(`## Assistant\n\n${m.content}\n`); - } else if (m.role === "tool") { - mdLines.push(`### Tool: ${m.toolName ?? "unknown"}\n\n\`\`\`\n${m.content}\n\`\`\`\n`); - } - } - mdLines.push(`---\n*Session cost: $${sessionCost.toFixed(4)} | Requests: ${totalRequests}*`); + if (slash === "/export" || slash === "/share") { + const pathArg = parts.slice(1).join(" ").trim() || undefined; + const outPath = resolveExportPath(sessionId, pathArg); + const md = renderSessionMarkdown({ + sessionId, + messages, + sessionCost, + totalRequests, + }); try { - writeFileSync(outPath, mdLines.join("\n"), "utf-8"); + writeFileSync(outPath, md, "utf-8"); pushSystemMsg(`Session exported to **${outPath}**`); } catch (e) { pushSystemMsg(`Export failed: ${e instanceof Error ? e.message : String(e)}`, "error"); @@ -4716,7 +4713,7 @@ export async function runREPL( { label: "Sessions", value: "sessions", description: "List saved sessions", color: "cyan" }, { label: "Compact Context", value: "compact", description: "Summarise to free context window", color: "yellow" }, { label: "Checkpoint", value: "checkpoint", description: "Snapshot modified files for rollback", color: "#fb923c" }, - { label: "Share / Export", value: "share", description: "Export session to markdown file", color: "#f9a8d4" }, + { label: "Export", value: "export", description: "Export session to Markdown file", color: "#f9a8d4" }, { label: "Git Diff", value: "diff", description: "Show git diff for all changes", color: "#60a5fa" }, { label: "Insert @ File", value: "at", description: "Pick a file to inject into message", color: "#34d399" }, { label: "Open in Editor", value: "editor", description: "Compose in $EDITOR (ctrl+x ctrl+e)", color: "white" }, @@ -4779,8 +4776,8 @@ export async function runREPL( } } else if (item.value === "checkpoint") { handleSlashCommand("/checkpoint"); - } else if (item.value === "share") { - handleSlashCommand("/share"); + } else if (item.value === "export") { + handleSlashCommand("/export"); } else if (item.value === "diff") { handleSlashCommand("/diff"); } else if (item.value === "at") {