351 lines
10 KiB
JavaScript
351 lines
10 KiB
JavaScript
//
|
|
// 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 };
|