First version

This commit is contained in:
2026-08-11 12:38:02 -03:00
parent 42e875d866
commit 89d1a06204
37 changed files with 5660 additions and 4 deletions
+350
View File
@@ -0,0 +1,350 @@
//
// 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 ? `<sub>${bits.join(" · ")}</sub>` : "";
}
_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(`<sub>with ${entry.context}</sub>`);
}
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(`<sub>read ${lines} line${lines === 1 ? "" : "s"}</sub>`);
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 };