// // transcript.js — renders the session into a Markdown tab. // // Nova sidebars can only hold tree items, so the readable conversation lives // in an editor tab. The document is rewritten in place: we diff the previous // render against the new one and replace only the tail that changed, which // keeps scrolling and selection stable while text streams in. // const { throttle, log, warn, modeLabel, formatCost, formatTokens } = require("./util.js"); const FILE_NAME = "Claude Transcript.md"; const RENDER_INTERVAL = 140; const MAX_RESULT_LINES = 12; const MAX_RESULT_CHARS = 1200; function storageDirectory() { return nova.workspace.path ? nova.extension.workspaceStoragePath : nova.extension.globalStoragePath; } /** Fence a block, widening the delimiter if the body contains backtick runs. */ function fence(text, language) { const body = String(text || "").replace(/\n+$/, ""); let longest = 0; const runs = body.match(/`+/g); if (runs) { for (const run of runs) longest = Math.max(longest, run.length); } const delimiter = "`".repeat(Math.max(3, longest + 1)); return delimiter + (language || "") + "\n" + body + "\n" + delimiter; } function truncateResult(text) { if (!text) return ""; let body = String(text); if (body.length > MAX_RESULT_CHARS) { body = body.slice(0, MAX_RESULT_CHARS) + "\n…"; } const lines = body.split("\n"); if (lines.length > MAX_RESULT_LINES) { return lines.slice(0, MAX_RESULT_LINES).join("\n") + `\n… ${lines.length - MAX_RESULT_LINES} more lines`; } return body; } const STATE_MARK = { running: "…", done: "✓", error: "✗", denied: "⊘", }; class Transcript { constructor(session) { this.session = session; this.editor = null; this.path = null; this.lastText = ""; this.disposables = []; this.showThinking = true; this.scheduleRender = throttle(() => this._render(), RENDER_INTERVAL); } dispose() { this.scheduleRender.cancel(); for (const d of this.disposables) { try { d.dispose(); } catch (_) { /* already disposed */ } } this.disposables = []; this.editor = null; } /** Open (or focus) the transcript tab. */ async show() { await this._ensureEditor(true); await this._render(); return this.editor; } /** Re-render soon; safe to call on every incoming token. */ update() { if (!this.editor) return; this.scheduleRender(); } /** Render immediately — used at the end of a turn. */ async flush() { this.scheduleRender.cancel(); await this._render(); await this._save(); } async _ensureEditor(focus) { if (this.editor && this.editor.document && !this.editor.document.isClosed) { if (focus && this.path) { await nova.workspace.openFile(this.path); } return this.editor; } const dir = storageDirectory(); try { nova.fs.mkdir(dir); } catch (_) { /* already exists */ } this.path = nova.path.join(dir, FILE_NAME); if (!nova.fs.access(this.path, nova.fs.F_OK)) { const file = nova.fs.open(this.path, "w"); file.write(""); file.close(); this.lastText = ""; } const editor = await nova.workspace.openFile(this.path); if (!editor) { warn("could not open the transcript tab"); return null; } this.editor = editor; this.lastText = editor.document.getTextInRange(new Range(0, editor.document.length)); this.disposables.push( editor.onDidDestroy(() => { this.editor = null; }) ); return editor; } async _save() { if (!this.editor || !this.editor.document || this.editor.document.isClosed) return; if (!this.editor.document.isDirty) return; try { await this.editor.save(); } catch (err) { log("could not save the transcript:", err); } } async _render() { if (!this.editor || !this.editor.document || this.editor.document.isClosed) return; const text = this.build(); if (text === this.lastText) return; const document = this.editor.document; const current = document.getTextInRange(new Range(0, document.length)); // Replace only the tail that differs, so the viewport stays put. let prefix = 0; const limit = Math.min(current.length, text.length); while (prefix < limit && current.charCodeAt(prefix) === text.charCodeAt(prefix)) { prefix++; } const wasAtEnd = this.editor.selectedRange.start >= current.length - 1; try { await this.editor.edit((edit) => { edit.replace(new Range(prefix, current.length), text.slice(prefix)); }); } catch (err) { warn("could not update the transcript:", err); return; } this.lastText = text; if (wasAtEnd) { const end = text.length; this.editor.selectedRange = new Range(end, end); this.editor.scrollToCursorPosition(); } } // -- markdown ---------------------------------------------------------- build() { const session = this.session; const lines = []; lines.push(`# Claude`); lines.push(""); lines.push(this._header()); lines.push(""); for (const entry of session.entries) { const block = this._renderEntry(entry); if (block) { lines.push(block); lines.push(""); } } if (session.approvals.length > 0) { lines.push("---"); lines.push(""); for (const approval of session.approvals) { const request = approval.request; lines.push( `> **Waiting for you:** \`${request.tool_name}\` — ${approval.summary || "needs approval"}` ); } lines.push(""); } if (session.status === "thinking") { lines.push("_Thinking…_"); lines.push(""); } else if (session.status === "working" && session.statusDetail) { lines.push(`_Working — ${session.statusDetail}_`); lines.push(""); } return lines.join("\n"); } _header() { const session = this.session; const bits = []; if (session.model) bits.push(session.model); if (session.permissionMode) bits.push(modeLabel(session.permissionMode)); const cost = formatCost(session.usage.costUSD); if (cost) bits.push(cost); const input = formatTokens(session.usage.inputTokens); const output = formatTokens(session.usage.outputTokens); if (input || output) bits.push(`${input || "0"} in / ${output || "0"} out`); if (session.sessionId) bits.push(`session ${session.sessionId.slice(0, 8)}`); return bits.length ? `${bits.join(" · ")}` : ""; } _renderEntry(entry) { switch (entry.kind) { case "user": return this._renderUser(entry); case "assistant": return this._renderAssistant(entry); case "tool": return this._renderTool(entry); case "notice": return this._renderNotice(entry); default: return null; } } _renderUser(entry) { const parts = ["## You", ""]; parts.push(entry.text); if (entry.context) { parts.push(""); parts.push(`with ${entry.context}`); } return parts.join("\n"); } _renderAssistant(entry) { const parts = []; for (const block of entry.blocks) { if (!block || !block.text || !block.text.trim()) continue; if (block.type === "thinking") { if (!this.showThinking) continue; parts.push("_Thinking_"); parts.push(""); parts.push( block.text .trim() .split("\n") .map((line) => "> " + line) .join("\n") ); parts.push(""); continue; } if (block.type === "text") { parts.push(block.text.replace(/\s+$/, "")); parts.push(""); } } if (parts.length === 0) return null; const body = parts.join("\n").replace(/\n+$/, ""); return ["## Claude", "", body].join("\n"); } _renderTool(entry) { const mark = STATE_MARK[entry.state] || "·"; const summary = entry.summary ? ` \`${entry.summary}\`` : ""; const head = `**${mark} ${entry.name}**${summary}`; const parts = [head]; if (entry.name === "Bash" && entry.input && entry.input.command) { parts.push(""); parts.push(fence(entry.input.command, "sh")); } if (entry.state === "denied") { parts.push(""); parts.push(`_Denied${entry.denyReason ? ": " + entry.denyReason : ""}_`); return parts.join("\n"); } // File contents Claude read are already on disk; echoing them here // buries the conversation. if (entry.name === "Read" && entry.state === "done") { const lines = String(entry.result || "").split("\n").length; parts.push(""); parts.push(`read ${lines} line${lines === 1 ? "" : "s"}`); return parts.join("\n"); } if (entry.result && entry.name !== "TodoWrite") { const body = truncateResult(entry.result).trim(); if (body) { parts.push(""); parts.push(fence(body, entry.state === "error" ? "" : "text")); } } return parts.join("\n"); } _renderNotice(entry) { const prefix = entry.tone === "error" ? "⚠︎ " : ""; return `> ${prefix}${entry.text.split("\n").join("\n> ")}`; } } module.exports = { Transcript };