Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ Every conversation is saved as a transcript in `~/.klaatai/sessions/`.
```
/sessions # list saved sessions
/resume <id> # pick up exactly where you left off
/share # export session to markdown
/export # export session to Markdown (optional path)
/share # alias for /export
```

### Permissions
Expand Down Expand Up @@ -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 <name>` · `/hooks` | Skills and hooks |
| `/init` | Generate project rules from your stack |
| `/sessions` · `/resume <id>` · `/share` | Session management |
| `/sessions` · `/resume <id>` · `/export [path]` · `/share` | Session management |
| `/mcp` | Manage MCP servers |
| `/agents` | List agent personas + running background sub-agents |
| `/perms` | Review tool permissions |
Expand Down
142 changes: 142 additions & 0 deletions src/screens/export-session.test.ts
Original file line number Diff line number Diff line change
@@ -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-<id>.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("<details>");
expect(md).toContain("<summary>read src/a.ts · 8 lines</summary>");
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("<details>");
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");
});
});
189 changes: 189 additions & 0 deletions src/screens/export-session.ts
Original file line number Diff line number Diff line change
@@ -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(`<details>`);
lines.push(`<summary>${label} · ${lineCount} lines</summary>`);
lines.push("");
lines.push(open);
lines.push(body);
lines.push(close);
lines.push("");
lines.push("</details>");
lines.push("");
return lines.join("\n");
}

/**
* Default export path: `./klaatai-session-<id>.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");
}
Loading
Loading