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
+234
View File
@@ -0,0 +1,234 @@
//
// auth.js — account status and sign-in, delegated to the Claude Code CLI.
//
// The extension never implements the OAuth flow itself: `claude auth login`
// runs Anthropic's own flow and stores the credentials where Claude Code
// expects them. We drive that command, surface the URL, and relay the code.
//
const { log, warn, runCommand, processWriter } = require("./util.js");
const cli = require("./cli.js");
const URL_PATTERN = new RegExp("https://[^\\s\"'<>]+", "g");
const LOGIN_URL_TIMEOUT = 20000;
/**
* Current account status.
* Returns { available, loggedIn, authMethod, email, subscriptionType, orgName, error }.
*/
async function status() {
let binary;
try {
binary = await cli.resolveBinary();
} catch (err) {
return { available: false, loggedIn: false, error: String(err.message || err) };
}
const result = await runCommand(binary, ["auth", "status", "--json"], {
env: cli.environment(),
timeout: 15000,
});
if (result.status !== 0 && !result.stdout.trim()) {
return {
available: true,
loggedIn: false,
error: result.stderr.trim() || `\`claude auth status\` exited with ${result.status}`,
};
}
try {
const parsed = JSON.parse(result.stdout.trim());
return Object.assign({ available: true, usingStoredKey: !!cli.storedApiKey() }, parsed);
} catch (err) {
return {
available: true,
loggedIn: false,
error: "Could not read the account status returned by Claude Code.",
};
}
}
/** A short line for the sidebar, e.g. "[email protected] · Pro". */
function describe(state) {
if (!state) return "Checking…";
if (!state.available) return "Claude Code not found";
if (state.error && !state.loggedIn) return "Signed out";
if (!state.loggedIn) return "Signed out";
const parts = [];
if (state.email) parts.push(state.email);
else if (state.authMethod === "apiKey") parts.push("API key");
if (state.subscriptionType) {
parts.push(state.subscriptionType.charAt(0).toUpperCase() + state.subscriptionType.slice(1));
} else if (state.authMethod) {
parts.push(state.authMethod);
}
return parts.join(" · ") || "Signed in";
}
/**
* Run `claude auth login`, opening the authorization URL in the browser and
* relaying the pasted code back to the CLI.
*
* Resolves true when the CLI exits successfully.
*/
async function signIn(options = {}) {
const binary = await cli.resolveBinary();
const args = ["auth", "login", options.console ? "--console" : "--claudeai"];
const process = new Process(binary, {
args: args,
cwd: nova.workspace.path || nova.environment["HOME"],
env: cli.environment(),
stdio: "pipe",
});
return new Promise((resolve, reject) => {
let writer = null;
let sawURL = false;
let finished = false;
let output = "";
const finish = (fn, value) => {
if (finished) return;
finished = true;
clearTimeout(urlTimer);
fn(value);
};
const handleLine = (line) => {
output += line;
log("auth login:", line.trim());
if (sawURL) return;
const matches = String(line).match(URL_PATTERN);
if (!matches || matches.length === 0) return;
const url = matches[0].replace(/[.,)\]]+$/, "");
if (!/oauth|authorize|login/i.test(url)) return;
sawURL = true;
nova.openURL(url);
promptForCode(url);
};
const promptForCode = (url) => {
nova.workspace.showInputPanel(
"Finish signing in to Claude in your browser, then paste the code it gives you.",
{
label: "Authorization code",
placeholder: "Paste code here",
prompt: "Sign In",
},
(value) => {
if (value === null) {
try {
process.terminate();
} catch (_) {
/* already gone */
}
finish(resolve, false);
return;
}
try {
if (!writer) writer = processWriter(process);
writer.write(value.trim() + "\n");
} catch (err) {
finish(reject, new Error("Could not send the code to Claude Code: " + err));
}
}
);
};
const urlTimer = setTimeout(() => {
if (sawURL || finished) return;
try {
process.terminate();
} catch (_) {
/* already gone */
}
finish(
reject,
new Error(
"Claude Code did not offer a sign-in link. Run `claude auth login` in a terminal to sign in."
)
);
}, LOGIN_URL_TIMEOUT);
process.onStdout(handleLine);
process.onStderr(handleLine);
process.onDidExit((code) => {
if (code === 0) {
finish(resolve, true);
} else {
finish(
reject,
new Error(
(output.trim().split("\n").pop() || "Sign-in failed.") +
` (claude auth login exited with ${code})`
)
);
}
});
try {
process.start();
} catch (err) {
finish(reject, err);
}
});
}
async function signOut() {
const binary = await cli.resolveBinary();
const result = await runCommand(binary, ["auth", "logout"], {
env: cli.environment(),
timeout: 15000,
});
if (result.status !== 0) {
throw new Error(result.stderr.trim() || `\`claude auth logout\` exited with ${result.status}`);
}
return true;
}
/**
* Fallback path: open a terminal window running `claude auth login`, for
* environments where the piped flow cannot complete (SSO, device prompts).
*/
async function signInViaTerminal() {
const binary = await cli.resolveBinary();
const dir = nova.extension.globalStoragePath;
try {
nova.fs.mkdir(dir);
} catch (_) {
/* already exists */
}
const scriptPath = nova.path.join(dir, "sign-in-to-claude.command");
const file = nova.fs.open(scriptPath, "w");
file.write("#!/bin/sh\n");
file.write("# Opened by the Claude extension for Nova.\n");
file.write(`exec ${JSON.stringify(binary)} auth login\n`);
file.close();
await runCommand("/bin/chmod", ["+x", scriptPath], { timeout: 5000 });
const result = await runCommand("/usr/bin/open", [scriptPath], { timeout: 5000 });
if (result.status !== 0) {
warn("could not open a terminal:", result.stderr);
throw new Error("Could not open a terminal window. Run `claude auth login` yourself to sign in.");
}
return true;
}
module.exports = {
status,
describe,
signIn,
signOut,
signInViaTerminal,
};
+156
View File
@@ -0,0 +1,156 @@
//
// cli.js — locating the Claude Code executable and building its environment.
//
const { conf, log, warn, runCommand } = require("./util.js");
const KEYCHAIN_SERVICE = "unsupervised.claudenova";
const KEYCHAIN_ACCOUNT = "ANTHROPIC_API_KEY";
// Standard install locations, in the order Claude Code's own installer prefers.
const CANDIDATE_PATHS = [
"/.local/bin/claude",
"/.claude/local/claude",
];
const CANDIDATE_ABSOLUTE = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
];
let cachedBinary = null;
function isExecutable(path) {
try {
return nova.fs.access(path, nova.fs.X_OK);
} catch (_) {
return false;
}
}
/** Ask the user's login shell where `claude` lives — catches nvm, mise, asdf, etc. */
async function resolveViaLoginShell() {
const shell = nova.environment["SHELL"] || "/bin/zsh";
const result = await runCommand(shell, ["-l", "-c", "command -v claude"], { timeout: 5000 });
if (result.status === 0) {
const path = result.stdout.trim().split("\n").pop();
if (path && path.startsWith("/") && isExecutable(path)) {
return path;
}
}
return null;
}
/**
* Find the `claude` executable.
* Resolution order: configured path, home-relative installs, system paths, login shell.
* The result is cached until `forgetBinary()` is called.
*/
async function resolveBinary() {
if (cachedBinary) return cachedBinary;
const configured = conf("claudenova.binaryPath", "").trim();
if (configured) {
if (isExecutable(configured)) {
cachedBinary = configured;
return cachedBinary;
}
throw new Error(
`The configured Claude Code executable is not runnable: ${configured}`
);
}
const home = nova.environment["HOME"] || "";
const candidates = CANDIDATE_PATHS.map((p) => home + p).concat(CANDIDATE_ABSOLUTE);
for (const candidate of candidates) {
if (isExecutable(candidate)) {
cachedBinary = candidate;
log("resolved claude binary:", candidate);
return cachedBinary;
}
}
const fromShell = await resolveViaLoginShell();
if (fromShell) {
cachedBinary = fromShell;
log("resolved claude binary via login shell:", fromShell);
return cachedBinary;
}
throw new Error(
"Claude Code was not found. Install it from https://claude.com/product/claude-code, " +
"or set the executable path in the extension's settings."
);
}
function forgetBinary() {
cachedBinary = null;
}
/** The API key the user stored in the keychain, if any. */
function storedApiKey() {
try {
return nova.credentials.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
} catch (err) {
warn("could not read the keychain:", err);
return null;
}
}
function setStoredApiKey(key) {
nova.credentials.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, key);
}
function clearStoredApiKey() {
try {
nova.credentials.removePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
} catch (err) {
warn("could not clear the keychain entry:", err);
}
}
/**
* Environment for spawned `claude` processes: Nova's task environment, plus a
* stored API key when the user supplied one instead of signing in.
*/
function environment(extra = {}) {
const env = Object.assign({}, nova.environment, extra);
const key = storedApiKey();
if (key) {
env["ANTHROPIC_API_KEY"] = key;
}
// Nova's environment can be sparse; make sure the CLI can find its own tooling.
if (!env["PATH"]) {
env["PATH"] = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
}
if (!env["HOME"] && nova.environment["HOME"]) {
env["HOME"] = nova.environment["HOME"];
}
return env;
}
async function version() {
const binary = await resolveBinary();
const result = await runCommand(binary, ["--version"], {
env: environment(),
timeout: 10000,
});
return result.status === 0 ? result.stdout.trim() : null;
}
module.exports = {
resolveBinary,
forgetBinary,
environment,
version,
storedApiKey,
setStoredApiKey,
clearStoredApiKey,
KEYCHAIN_SERVICE,
KEYCHAIN_ACCOUNT,
};
+386
View File
@@ -0,0 +1,386 @@
//
// client.js — drives `claude` in streaming JSON mode.
//
// The CLI speaks newline-delimited JSON on stdio. Two kinds of traffic share
// that channel:
//
// * session messages — system / assistant / user / result / stream_event
// * control traffic — request/response pairs in both directions, used for
// the initialize handshake, permission prompts
// (`can_use_tool`), interrupts and live setting changes
//
// This class owns the process and the framing; everything above it deals in
// parsed messages.
//
const { log, warn, processWriter } = require("./util.js");
const cli = require("./cli.js");
const CONTROL_TIMEOUT = 60000;
function uuid() {
const bytes = new Uint8Array(16);
if (nova.crypto && typeof nova.crypto.getRandomValues === "function") {
nova.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
const hex = [];
for (let i = 0; i < bytes.length; i++) {
hex.push(bytes[i].toString(16).padStart(2, "0"));
}
const s = hex.join("");
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
}
class ClaudeClient {
/**
* @param {object} handlers
* onMessage(message) — a session message from the CLI
* onPermission(request) — must resolve to a PermissionResult
* onPermissionCancelled(id) — the CLI withdrew a pending prompt
* onExit(status, stderr) — the process ended
* onError(error) — transport-level failure
*/
constructor(handlers = {}) {
this.handlers = handlers;
this.process = null;
this.writer = null;
this.sessionId = null;
this.running = false;
this.stdoutBuffer = "";
this.stderrLines = [];
this.pendingControl = new Map();
this.inFlightPermissions = new Map();
}
get isRunning() {
return this.running;
}
/**
* Build the command line. Kept separate so it can be inspected and tested.
*/
buildArgs(options) {
const args = [
"--print",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
// Route permission asks over the control channel to `onPermission`
// instead of auto-denying them.
"--permission-prompt-tool",
"stdio",
];
if (options.streamPartial) args.push("--include-partial-messages");
if (options.resume) {
args.push("--resume", options.resume);
} else {
args.push("--session-id", options.sessionId);
}
if (options.model) args.push("--model", options.model);
if (options.effort) args.push("--effort", options.effort);
if (options.permissionMode) args.push("--permission-mode", options.permissionMode);
for (const dir of options.additionalDirectories || []) {
args.push("--add-dir", dir);
}
for (const extra of options.extraArgs || []) {
args.push(extra);
}
return args;
}
/** Launch the CLI and complete the initialize handshake. */
async start(options = {}) {
if (this.running) {
throw new Error("A Claude session is already running.");
}
const binary = await cli.resolveBinary();
this.sessionId = options.resume || options.sessionId || uuid();
const args = this.buildArgs(
Object.assign({}, options, { sessionId: this.sessionId })
);
log("starting:", binary, args.join(" "));
this.stdoutBuffer = "";
this.stderrLines = [];
this.process = new Process(binary, {
args: args,
cwd: options.cwd || nova.workspace.path || nova.environment["HOME"],
env: cli.environment(),
stdio: "pipe",
});
this.process.onStdout((chunk) => this._onStdout(chunk));
this.process.onStderr((chunk) => this._onStderr(chunk));
this.process.onDidExit((status) => this._onExit(status));
this.process.start();
this.writer = processWriter(this.process);
this.running = true;
try {
await this._control({ subtype: "initialize" }, 20000);
} catch (err) {
// A failed handshake is worth reporting, but older CLIs still work
// for plain message traffic, so the session continues.
warn("initialize handshake failed:", err.message || err);
}
return this.sessionId;
}
/** Send a user turn. `content` is a string or an array of content blocks. */
send(content) {
this._write({
type: "user",
message: {
role: "user",
content: content,
},
parent_tool_use_id: null,
});
}
async interrupt() {
return this._control({ subtype: "interrupt" }, 15000);
}
async setPermissionMode(mode) {
return this._control({ subtype: "set_permission_mode", mode: mode });
}
async setModel(model) {
return this._control({ subtype: "set_model", model: model || undefined });
}
/** Stop the session. Graceful by default; `force` sends SIGKILL. */
stop(force = false) {
if (!this.process) return;
for (const [, pending] of this.pendingControl) {
pending.reject(new Error("The Claude session ended."));
}
this.pendingControl.clear();
this.inFlightPermissions.clear();
try {
if (this.writer) this.writer.close();
} catch (_) {
/* already closed */
}
try {
if (force) this.process.kill();
else this.process.terminate();
} catch (err) {
warn("could not stop the process:", err);
}
this.running = false;
}
// -- internals ---------------------------------------------------------
_write(object) {
if (!this.writer) {
throw new Error("The Claude session is not running.");
}
this.writer.write(JSON.stringify(object) + "\n");
}
/** Send a control request and wait for its matching response. */
_control(request, timeout = CONTROL_TIMEOUT) {
return new Promise((resolve, reject) => {
if (!this.running && request.subtype !== "initialize") {
reject(new Error("The Claude session is not running."));
return;
}
const requestId = "nova-" + uuid();
const timer = setTimeout(() => {
this.pendingControl.delete(requestId);
reject(new Error(`Claude Code did not answer "${request.subtype}" in time.`));
}, timeout);
this.pendingControl.set(requestId, {
resolve: (value) => {
clearTimeout(timer);
this.pendingControl.delete(requestId);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
this.pendingControl.delete(requestId);
reject(err);
},
});
try {
this._write({ type: "control_request", request_id: requestId, request: request });
} catch (err) {
const pending = this.pendingControl.get(requestId);
if (pending) pending.reject(err);
}
});
}
_onStdout(chunk) {
this.stdoutBuffer += chunk;
let newline = this.stdoutBuffer.indexOf("\n");
while (newline >= 0) {
const line = this.stdoutBuffer.slice(0, newline).trim();
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
if (line) this._handleLine(line);
newline = this.stdoutBuffer.indexOf("\n");
}
}
_onStderr(chunk) {
const text = String(chunk).trim();
if (!text) return;
this.stderrLines.push(text);
if (this.stderrLines.length > 200) this.stderrLines.shift();
log("stderr:", text);
}
_handleLine(line) {
let message;
try {
message = JSON.parse(line);
} catch (err) {
// Anything that is not JSON is CLI chatter, not protocol traffic.
log("non-JSON output:", line.slice(0, 200));
return;
}
switch (message.type) {
case "control_response":
this._handleControlResponse(message.response);
return;
case "control_request":
this._handleControlRequest(message);
return;
case "control_cancel_request":
this._handleControlCancel(message.request_id);
return;
case "keep_alive":
case "transcript_mirror":
return;
default:
break;
}
if (message.session_id) {
this.sessionId = message.session_id;
}
if (this.handlers.onMessage) {
this.handlers.onMessage(message);
}
}
_handleControlResponse(response) {
if (!response || !response.request_id) return;
const pending = this.pendingControl.get(response.request_id);
if (!pending) return;
if (response.subtype === "success") {
pending.resolve(response.response);
} else {
pending.reject(new Error(response.error || "Claude Code reported a control error."));
}
}
async _handleControlRequest(message) {
const requestId = message.request_id;
const request = message.request || {};
try {
let result;
if (request.subtype === "can_use_tool") {
if (!this.handlers.onPermission) {
throw new Error("No permission handler is registered.");
}
// A handler that throws must still produce an answer: the CLI
// blocks on this request, so silence would hang the session.
this.inFlightPermissions.set(requestId, request);
result = await this.handlers.onPermission(request, requestId);
if (!this.inFlightPermissions.has(requestId)) {
// Cancelled while the user was deciding; the CLI moved on.
return;
}
this.inFlightPermissions.delete(requestId);
result = Object.assign({ toolUseID: request.tool_use_id }, result);
} else {
throw new Error("Unsupported control request subtype: " + request.subtype);
}
this._write({
type: "control_response",
response: { subtype: "success", request_id: requestId, response: result },
});
} catch (err) {
this.inFlightPermissions.delete(requestId);
try {
this._write({
type: "control_response",
response: {
subtype: "error",
request_id: requestId,
error: String((err && err.message) || err),
},
});
} catch (writeErr) {
warn("could not answer a control request:", writeErr);
}
}
}
_handleControlCancel(requestId) {
if (this.inFlightPermissions.has(requestId)) {
this.inFlightPermissions.delete(requestId);
if (this.handlers.onPermissionCancelled) {
this.handlers.onPermissionCancelled(requestId);
}
}
}
_onExit(status) {
this.running = false;
this.writer = null;
for (const [, pending] of this.pendingControl) {
pending.reject(new Error("The Claude session ended."));
}
this.pendingControl.clear();
const stderr = this.stderrLines.join("\n");
log("process exited with", status);
if (this.handlers.onExit) {
this.handlers.onExit(status, stderr);
}
}
}
module.exports = { ClaudeClient, uuid };
+299
View File
@@ -0,0 +1,299 @@
//
// diff.js — previewing a change before you approve it.
//
// Nova has no scriptable diff viewer, so proposed edits are rendered as a
// unified diff in a scratch tab. That is enough to answer the only question
// that matters at approval time: what exactly is about to change on disk.
//
const { relativize, warn } = require("./util.js");
const CONTEXT_LINES = 3;
const MAX_DIFF_LINES = 400;
function readFile(path) {
try {
if (!nova.fs.access(path, nova.fs.R_OK)) return null;
const file = nova.fs.open(path, "r");
const contents = file.readlines().join("");
file.close();
return contents;
} catch (err) {
warn("could not read", path, err);
return null;
}
}
function splitLines(text) {
if (text === null || text === undefined) return [];
const body = String(text);
if (body === "") return [];
// A trailing newline terminates the last line rather than starting a new
// empty one — otherwise every diff carries a phantom blank line.
const lines = body.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
return lines;
}
/**
* Longest-common-subsequence line diff.
* Common prefixes and suffixes are trimmed first, so the quadratic table only
* ever covers the part that actually differs.
*/
function diffLines(beforeLines, afterLines) {
let start = 0;
while (
start < beforeLines.length &&
start < afterLines.length &&
beforeLines[start] === afterLines[start]
) {
start++;
}
let endBefore = beforeLines.length;
let endAfter = afterLines.length;
while (
endBefore > start &&
endAfter > start &&
beforeLines[endBefore - 1] === afterLines[endAfter - 1]
) {
endBefore--;
endAfter--;
}
const a = beforeLines.slice(start, endBefore);
const b = afterLines.slice(start, endAfter);
const ops = [];
for (let i = 0; i < start; i++) ops.push({ type: "equal", text: beforeLines[i] });
// Guard the table: very large rewrites are shown as a plain replacement.
if (a.length * b.length > 1000000) {
for (const line of a) ops.push({ type: "delete", text: line });
for (const line of b) ops.push({ type: "insert", text: line });
} else {
const table = [];
for (let i = 0; i <= a.length; i++) {
table.push(new Array(b.length + 1).fill(0));
}
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i][j] =
a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
}
}
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
ops.push({ type: "equal", text: a[i] });
i++;
j++;
} else if (table[i + 1][j] >= table[i][j + 1]) {
ops.push({ type: "delete", text: a[i] });
i++;
} else {
ops.push({ type: "insert", text: b[j] });
j++;
}
}
while (i < a.length) ops.push({ type: "delete", text: a[i++] });
while (j < b.length) ops.push({ type: "insert", text: b[j++] });
}
for (let i = endBefore; i < beforeLines.length; i++) {
ops.push({ type: "equal", text: beforeLines[i] });
}
return ops;
}
/** Render ops as a unified diff with limited context. */
function renderUnified(ops) {
const keep = new Array(ops.length).fill(false);
for (let i = 0; i < ops.length; i++) {
if (ops[i].type === "equal") continue;
for (let j = Math.max(0, i - CONTEXT_LINES); j <= Math.min(ops.length - 1, i + CONTEXT_LINES); j++) {
keep[j] = true;
}
}
const lines = [];
let skipping = false;
let shown = 0;
for (let i = 0; i < ops.length; i++) {
if (!keep[i]) {
if (!skipping) {
lines.push("@@ …");
skipping = true;
}
continue;
}
skipping = false;
if (shown >= MAX_DIFF_LINES) {
lines.push(`@@ … diff truncated after ${MAX_DIFF_LINES} lines`);
break;
}
const op = ops[i];
const marker = op.type === "insert" ? "+" : op.type === "delete" ? "-" : " ";
lines.push(marker + op.text);
shown++;
}
return lines.join("\n");
}
function counts(ops) {
let additions = 0;
let deletions = 0;
for (const op of ops) {
if (op.type === "insert") additions++;
else if (op.type === "delete") deletions++;
}
return { additions, deletions };
}
/**
* Work out what a file would contain if a pending tool call ran.
* Returns { path, before, after, title, exists } or null when the tool does not
* describe a file change we can reconstruct.
*/
function proposedChange(toolName, input) {
if (!input) return null;
const path = input.file_path;
if (!path) return null;
if (toolName === "Write") {
const before = readFile(path);
return {
path: path,
before: before,
after: String(input.content || ""),
exists: before !== null,
title: before === null ? `Create ${relativize(path)}` : `Overwrite ${relativize(path)}`,
};
}
if (toolName === "Edit" || toolName === "Update") {
if (typeof input.old_string !== "string") return null;
const before = readFile(path);
if (before === null) {
// The file is not readable — fall back to showing the replacement
// on its own, which still tells the user what Claude intends.
return {
path: path,
before: input.old_string,
after: String(input.new_string || ""),
exists: false,
title: `Edit ${relativize(path)}`,
};
}
const after = input.replace_all
? before.split(input.old_string).join(input.new_string || "")
: before.replace(input.old_string, input.new_string || "");
return {
path: path,
before: before,
after: after,
exists: true,
title: `Edit ${relativize(path)}`,
};
}
if (toolName === "MultiEdit" && Array.isArray(input.edits)) {
const before = readFile(path);
if (before === null) return null;
let after = before;
for (const edit of input.edits) {
if (typeof edit.old_string !== "string") continue;
after = edit.replace_all
? after.split(edit.old_string).join(edit.new_string || "")
: after.replace(edit.old_string, edit.new_string || "");
}
return {
path: path,
before: before,
after: after,
exists: true,
title: `Edit ${relativize(path)} (${input.edits.length} changes)`,
};
}
return null;
}
/**
* Build a preview for a pending tool call.
* Returns { title, path, diff, additions, deletions, before, after } or null.
*/
function previewForTool(toolName, input) {
const change = proposedChange(toolName, input);
if (!change) return null;
const ops = diffLines(splitLines(change.before), splitLines(change.after));
const stats = counts(ops);
return {
title: change.title,
path: change.path,
before: change.before,
after: change.after,
exists: change.exists,
diff: renderUnified(ops),
additions: stats.additions,
deletions: stats.deletions,
};
}
/** Line number (1-based) where `needle` starts in the file, or null. */
function lineOfMatch(path, needle) {
if (!needle) return null;
const contents = readFile(path);
if (contents === null) return null;
const index = contents.indexOf(needle);
if (index < 0) return null;
return contents.slice(0, index).split("\n").length;
}
/** Open a scratch tab showing the proposed change. */
async function showPreview(preview) {
if (!preview) return null;
const header = [
`--- a/${relativize(preview.path)}`,
`+++ b/${relativize(preview.path)}`,
].join("\n");
const content = `${header}\n${preview.diff}\n`;
try {
return await nova.workspace.openNewTextDocument({
content: content,
syntax: "diff",
});
} catch (err) {
warn("could not open the change preview:", err);
return null;
}
}
module.exports = {
previewForTool,
proposedChange,
showPreview,
readFile,
lineOfMatch,
diffLines,
renderUnified,
};
+744
View File
@@ -0,0 +1,744 @@
//
// main.js — extension entry point.
//
// Owns the lifecycle: one Claude Code process per window, a session model fed
// from its output, and the views and commands that surface it.
//
const { conf, log, warn, relativize, oneLine } = require("./util.js");
const cli = require("./cli.js");
const auth = require("./auth.js");
const diff = require("./diff.js");
const store = require("./sessions-store.js");
const { ClaudeClient } = require("./client.js");
const { Session } = require("./session.js");
const { Transcript } = require("./transcript.js");
const { Sidebar } = require("./sidebar.js");
const { PermissionBroker } = require("./permissions.js");
const MODELS = [
["", "Default (from your Claude Code settings)"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
["fable", "Fable"],
];
const PERMISSION_MODES = [
["manual", "Manual — ask before each tool"],
["acceptEdits", "Accept Edits — auto-approve file edits"],
["plan", "Plan — research only, no changes"],
["auto", "Auto — Claude decides what needs asking"],
["bypassPermissions", "Bypass — approve everything (dangerous)"],
];
class Controller {
constructor() {
this.session = new Session();
this.transcript = new Transcript(this.session);
this.broker = new PermissionBroker(this.session, this.transcript);
this.client = null;
this.authState = null;
this.sidebar = null;
this.starting = null;
}
// -- lifecycle ---------------------------------------------------------
activate() {
this.transcript.showThinking = conf("claudenova.showThinking", true) !== false;
const controller = this;
this.sidebar = new Sidebar({
session: this.session,
get authState() {
return controller.authState;
},
describeAuth: () => auth.describe(this.authState),
configuredModel: () => this.labelFor(MODELS, conf("claudenova.model", "")),
configuredPermissionMode: () =>
this.labelFor(PERMISSION_MODES, conf("claudenova.permissionMode", "manual")),
});
this.session.onChange.on(() => {
this.sidebar.reload();
this.transcript.update();
});
this.registerCommands();
this.watchConfig();
this.refreshAuth().catch((err) => log("initial auth check failed:", err));
}
dispose() {
if (this.client) this.client.stop(true);
this.transcript.dispose();
if (this.sidebar) this.sidebar.dispose();
}
labelFor(table, value) {
const row = table.find((entry) => entry[0] === (value || ""));
return row ? row[1] : value;
}
watchConfig() {
const reloadThinking = () => {
this.transcript.showThinking = conf("claudenova.showThinking", true) !== false;
this.transcript.update();
};
nova.subscriptions.add(nova.config.onDidChange("claudenova.showThinking", reloadThinking));
nova.subscriptions.add(
nova.workspace.config.onDidChange("claudenova.showThinking", reloadThinking)
);
nova.subscriptions.add(
nova.config.onDidChange("claudenova.binaryPath", () => cli.forgetBinary())
);
}
// -- account -----------------------------------------------------------
async refreshAuth() {
this.authState = await auth.status();
this.sidebar.reload();
return this.authState;
}
// -- session -----------------------------------------------------------
sessionOptions(overrides = {}) {
const extra = String(conf("claudenova.extraArgs", "")).trim();
return Object.assign(
{
cwd: nova.workspace.path || undefined,
model: conf("claudenova.model", "") || undefined,
effort: conf("claudenova.effort", "") || undefined,
permissionMode: conf("claudenova.permissionMode", "manual"),
streamPartial: conf("claudenova.streamPartial", true) !== false,
extraArgs: extra ? extra.split(/\s+/) : [],
},
overrides
);
}
/** Start a session if one is not already running. */
async ensureSession() {
if (this.client && this.client.isRunning) return this.client;
if (this.starting) return this.starting;
this.starting = this.startSession().finally(() => {
this.starting = null;
});
return this.starting;
}
async startSession(overrides = {}) {
if (this.client) this.client.stop();
this.clearStagedProposals();
this.session.reset();
this.session.setStatus("starting");
const client = new ClaudeClient({
onMessage: (message) => this.session.handleMessage(message),
onPermission: (request, requestId) => this.broker.handle(request, requestId),
onPermissionCancelled: (requestId) => this.broker.cancel(requestId),
onExit: (status, stderr) => this.handleExit(status, stderr),
});
this.client = client;
try {
await client.start(this.sessionOptions(overrides));
} catch (err) {
this.client = null;
this.session.setStatus("error", String(err.message || err));
throw err;
}
this.session.sessionId = client.sessionId;
this.session.setStatus("idle");
if (conf("claudenova.openTranscript", true) !== false) {
await this.transcript.show();
}
return client;
}
/** Remove proposal files left over from a previous session. */
clearStagedProposals() {
const root = nova.path.join(
nova.workspace.path
? nova.extension.workspaceStoragePath
: nova.extension.globalStoragePath,
"proposed"
);
try {
if (!nova.fs.access(root, nova.fs.F_OK)) return;
for (const name of nova.fs.listdir(root)) {
nova.fs.remove(nova.path.join(root, name));
}
} catch (err) {
log("could not clear staged proposals:", err);
}
}
handleExit(status, stderr) {
const wasBusy = this.session.isBusy;
this.session.setStatus("stopped");
for (const approval of this.session.approvals.slice()) {
approval.settled = true;
this.session.removeApproval(approval.requestId);
}
if (status !== 0 && wasBusy) {
const detail = oneLine(stderr, 300) || `Claude Code exited with status ${status}.`;
this.session.addNotice(detail, "error");
nova.workspace.showErrorMessage("Claude Code stopped unexpectedly.\n\n" + detail);
}
this.transcript.flush().catch(() => {
/* the tab may be closed */
});
}
// -- asking ------------------------------------------------------------
async ask(text, context) {
if (!text || !text.trim()) return;
try {
await this.ensureSession();
} catch (err) {
nova.workspace.showErrorMessage(String(err.message || err));
return;
}
this.session.addUserTurn(text, context ? context.label : null);
await this.transcript.show();
try {
this.client.send(context ? context.content(text) : text);
} catch (err) {
this.session.setStatus("error", String(err.message || err));
nova.workspace.showErrorMessage("Could not send your message: " + (err.message || err));
}
}
promptAndAsk(prefill, context) {
nova.workspace.showInputPalette(
context ? `Ask Claude about ${context.label}` : "Ask Claude",
{ placeholder: "What would you like Claude to do?", value: prefill || "" },
(value) => {
if (value === null) return;
this.ask(value, context);
}
);
}
/** Build the context payload for the current editor selection. */
selectionContext(editor) {
if (!editor) return null;
if (conf("claudenova.includeSelection", true) === false) return null;
const range = editor.selectedRange;
if (!range || range.empty) return null;
const document = editor.document;
const path = document.path;
if (!path) return null;
const before = document.getTextInRange(new Range(0, range.start));
const startLine = before.split("\n").length;
const selected = document.getTextInRange(range);
const endLine = startLine + selected.split("\n").length - 1;
const label = `${relativize(path)}:${startLine}-${endLine}`;
const syntax = document.syntax || "";
return {
label: label,
content: (question) =>
[
question,
"",
`From \`${relativize(path)}\` lines ${startLine}${endLine}:`,
"```" + syntax,
selected,
"```",
].join("\n"),
};
}
fileContext(editor) {
const path = editor && editor.document && editor.document.path;
if (!path) return null;
return {
label: relativize(path),
content: (question) => `${question}\n\nThe file in question is \`${path}\`.`,
};
}
// -- commands ----------------------------------------------------------
registerCommands() {
const register = (name, callback) => {
nova.subscriptions.add(nova.commands.register(name, callback));
};
register("claudenova.ask", (editor) => {
const context = TextEditor.isTextEditor(editor)
? this.selectionContext(editor)
: this.selectionContext(nova.workspace.activeTextEditor);
this.promptAndAsk("", context);
});
register("claudenova.askAboutSelection", (editor) => {
const context = this.selectionContext(editor);
if (!context) {
nova.workspace.showWarningMessage("Select some code first, then ask Claude about it.");
return;
}
this.promptAndAsk("", context);
});
register("claudenova.addFiles", () => this.commandAddFiles());
register("claudenova.addFileContext", (editor) => {
const context = this.fileContext(editor);
if (!context) {
nova.workspace.showWarningMessage("Save the file first so Claude can read it.");
return;
}
this.promptAndAsk("", context);
});
register("claudenova.plan", (editor) => {
const context = TextEditor.isTextEditor(editor)
? this.selectionContext(editor)
: this.selectionContext(nova.workspace.activeTextEditor);
this.commandPlan(context);
});
register("claudenova.showTranscript", () => {
this.transcript.show().catch((err) => warn("could not open the transcript:", err));
});
register("claudenova.newSession", () => this.commandNewSession());
register("claudenova.resumeSession", () => this.commandResumeSession());
register("claudenova.interrupt", () => this.commandInterrupt());
register("claudenova.endSession", () => this.commandEndSession());
register("claudenova.setModel", () => this.commandSetModel());
register("claudenova.setPermissionMode", () => this.commandSetPermissionMode());
register("claudenova.signIn", () => this.commandSignIn());
register("claudenova.signOut", () => this.commandSignOut());
register("claudenova.authStatus", () => this.commandAuthStatus());
register("claudenova.refreshStatus", () => this.refreshAuth());
register("claudenova.setApiKey", () => this.commandSetApiKey());
register("claudenova.clearApiKey", () => this.commandClearApiKey());
register("claudenova.approvalAction", () => this.commandApprovalAction());
register("claudenova.reviewApproval", () => this.approvalCommand("review"));
register("claudenova.allowApproval", () => this.approvalCommand("allow"));
register("claudenova.allowApprovalAlways", () => this.approvalCommand("always"));
register("claudenova.denyApproval", () => this.approvalCommand("deny"));
register("claudenova.openActivityFile", () => this.commandOpenActivityFile());
register("claudenova.copyActivity", () => this.commandCopyActivity());
register("claudenova.openChangedFile", () => this.commandOpenChangedFile());
register("claudenova.showChangeDiff", () => this.commandShowChangeDiff());
register("claudenova.clearChanges", () => this.session.clearChangedFiles());
}
/**
* Ask in plan mode: Claude researches and proposes, changing nothing until
* you approve the plan. Switches a running session over rather than
* starting a new one, so the existing context carries into the planning.
*/
async commandPlan(context) {
try {
await this.ensureSession();
} catch (err) {
nova.workspace.showErrorMessage(String(err.message || err));
return;
}
if (this.session.permissionMode !== "plan") {
try {
await this.client.setPermissionMode("plan");
this.session.permissionMode = "plan";
this.session.changed();
} catch (err) {
warn("could not switch to plan mode:", err);
nova.workspace.showWarningMessage(
"Could not switch to plan mode: " + (err.message || err)
);
return;
}
}
nova.workspace.showInputPalette(
context ? `Plan with Claude about ${context.label}` : "Plan with Claude",
{ placeholder: "What should Claude plan?" },
(value) => {
if (value === null) return;
this.ask(value, context);
}
);
}
/**
* Pick files (or a directory) to put in front of Claude, with the current
* selection's line range attached when there is one. The editor-menu
* command covers the active file; this covers everything else.
*/
commandAddFiles() {
nova.workspace.showFileChooser(
"Choose files for Claude to work with",
{
prompt: "Add",
allowFiles: true,
allowDirectories: true,
allowMultiple: true,
},
(paths) => {
if (!paths || paths.length === 0) return;
const listed = paths.map((path) => "`" + relativize(path) + "`").join(", ");
const label = paths.length === 1 ? relativize(paths[0]) : `${paths.length} files`;
this.promptAndAsk("", {
label: label,
content: (question) =>
`${question}\n\nWork with ${paths.length === 1 ? "this file" : "these files"}: ${listed}`,
});
}
);
}
async commandNewSession() {
try {
await this.startSession();
this.session.addNotice("New session started.", "info");
} catch (err) {
nova.workspace.showErrorMessage(String(err.message || err));
}
}
commandResumeSession() {
const sessions = store.recentSessions(nova.workspace.path);
if (sessions.length === 0) {
nova.workspace.showInformativeMessage("No previous Claude sessions were found for this project.");
return;
}
const choices = sessions.map((session) => {
const when = session.modifiedAt.toLocaleString();
return `${session.label}${when}`;
});
nova.workspace.showChoicePalette(
choices,
{ placeholder: "Resume a Claude session" },
(choice, index) => {
if (index === null || index === undefined) return;
const picked = sessions[index];
this.startSession({ resume: picked.id })
.then(() => {
this.session.addNotice(`Resumed session ${picked.id.slice(0, 8)}.`, "info");
})
.catch((err) => nova.workspace.showErrorMessage(String(err.message || err)));
}
);
}
async commandInterrupt() {
if (!this.client || !this.client.isRunning) {
nova.workspace.showInformativeMessage("No Claude session is running.");
return;
}
try {
await this.client.interrupt();
this.session.addNotice("Interrupted.", "info");
this.session.setStatus("idle");
} catch (err) {
warn("interrupt failed:", err);
nova.workspace.showWarningMessage("Could not interrupt Claude: " + (err.message || err));
}
}
commandEndSession() {
if (!this.client) {
nova.workspace.showInformativeMessage("No Claude session is running.");
return;
}
this.client.stop();
this.client = null;
this.session.setStatus("stopped");
this.transcript.flush().catch(() => {
/* the tab may be closed */
});
}
commandSetModel() {
const labels = MODELS.map((entry) => entry[1]);
nova.workspace.showChoicePalette(labels, { placeholder: "Model" }, (choice, index) => {
if (index === null || index === undefined) return;
const value = MODELS[index][0];
nova.workspace.config.set("claudenova.model", value);
if (this.client && this.client.isRunning) {
this.client
.setModel(value || undefined)
.then(() => {
this.session.model = value || null;
this.session.changed();
})
.catch((err) => warn("could not change the model:", err));
}
});
}
commandSetPermissionMode() {
const labels = PERMISSION_MODES.map((entry) => entry[1]);
nova.workspace.showChoicePalette(labels, { placeholder: "Permission mode" }, (choice, index) => {
if (index === null || index === undefined) return;
const value = PERMISSION_MODES[index][0];
nova.workspace.config.set("claudenova.permissionMode", value);
if (this.client && this.client.isRunning) {
this.client
.setPermissionMode(value)
.then(() => {
this.session.permissionMode = value;
this.session.changed();
})
.catch((err) => warn("could not change the permission mode:", err));
}
});
}
// -- account commands --------------------------------------------------
commandSignIn() {
nova.workspace.showActionPanel(
"How would you like to sign in to Claude?",
{
buttons: ["Claude Subscription", "Anthropic Console", "Open a Terminal", "Cancel"],
},
(index) => {
if (index === null || index === 3) return;
if (index === 2) {
auth.signInViaTerminal()
.then(() => {
nova.workspace.showInformativeMessage(
"Finish signing in in the terminal window, then use Refresh in the Claude sidebar."
);
})
.catch((err) => nova.workspace.showErrorMessage(String(err.message || err)));
return;
}
auth.signIn({ console: index === 1 })
.then(async (ok) => {
if (!ok) return;
await this.refreshAuth();
nova.workspace.showInformativeMessage("Signed in to Claude as " + auth.describe(this.authState));
})
.catch((err) => {
nova.workspace.showErrorMessage(
String(err.message || err) +
"\n\nYou can also sign in by running `claude auth login` in a terminal."
);
});
}
);
}
commandSignOut() {
auth.signOut()
.then(() => this.refreshAuth())
.then(() => nova.workspace.showInformativeMessage("Signed out of Claude."))
.catch((err) => nova.workspace.showErrorMessage(String(err.message || err)));
}
async commandAuthStatus() {
const state = await this.refreshAuth();
const lines = [];
if (!state.available) {
lines.push("Claude Code was not found on this machine.");
if (state.error) lines.push(state.error);
} else if (state.loggedIn) {
lines.push("Signed in to Claude.");
if (state.email) lines.push("Account: " + state.email);
if (state.subscriptionType) lines.push("Plan: " + state.subscriptionType);
if (state.orgName) lines.push("Organization: " + state.orgName);
if (state.authMethod) lines.push("Method: " + state.authMethod);
} else {
lines.push("Not signed in.");
if (state.error) lines.push(state.error);
}
if (cli.storedApiKey()) {
lines.push("");
lines.push("An API key stored in your keychain is being passed to Claude Code.");
}
const version = await cli.version().catch(() => null);
if (version) {
lines.push("");
lines.push("Claude Code " + version);
}
nova.workspace.showInformativeMessage(lines.join("\n"));
}
commandSetApiKey() {
nova.workspace.showInputPanel(
"Store an Anthropic API key in your keychain. Claude Code will use it instead of your signed-in account.",
{ label: "API key", placeholder: "sk-ant-…", secure: true, prompt: "Save" },
(value) => {
if (value === null) return;
const key = value.trim();
if (!key) return;
try {
cli.setStoredApiKey(key);
nova.workspace.showInformativeMessage(
"Saved. Start a new Claude session for it to take effect."
);
this.refreshAuth();
} catch (err) {
nova.workspace.showErrorMessage("Could not save the key: " + (err.message || err));
}
}
);
}
commandClearApiKey() {
cli.clearStoredApiKey();
nova.workspace.showInformativeMessage(
"Removed the stored API key. Start a new Claude session for it to take effect."
);
this.refreshAuth();
}
// -- approval commands -------------------------------------------------
/** An action row under a pending request was activated. */
commandApprovalAction() {
const selection = this.sidebar.approvalsView.selection;
const row = selection && selection[0];
if (!row || !row.approval) return;
this.broker.decide(row.approval, row.id);
}
approvalCommand(choice) {
const selection = this.sidebar.approvalsView.selection;
const selected = selection && selection[0];
// Selecting an action row and using the context menu should still act
// on its parent request.
const approval =
(selected && (selected.approval || (selected.request ? selected : null))) ||
this.session.approvals[0];
if (!approval) {
nova.workspace.showInformativeMessage("Nothing is waiting for approval.");
return;
}
this.broker.decide(approval, choice);
}
// -- activity and changes ----------------------------------------------
commandOpenActivityFile() {
const selection = this.sidebar.activityView.selection;
const entry = selection && selection[0];
if (!entry) return;
if (entry.filePath) {
nova.workspace.openFile(entry.filePath);
} else {
this.commandCopyActivity();
}
}
commandCopyActivity() {
const selection = this.sidebar.activityView.selection;
const entry = selection && selection[0];
if (!entry) return;
const details = [
`${entry.name}${entry.state}`,
"",
JSON.stringify(entry.input, null, 2),
];
if (entry.result) {
details.push("", "Result:", entry.result);
}
nova.clipboard.writeText(details.join("\n"));
nova.workspace.showInformativeMessage("Copied the tool call to the clipboard.");
}
commandOpenChangedFile() {
const selection = this.sidebar.changesView.selection;
for (const change of selection || []) {
nova.workspace.openFile(change.path);
}
}
commandShowChangeDiff() {
const selection = this.sidebar.changesView.selection;
const change = selection && selection[0];
if (!change) return;
const tool = this.session.entries
.filter((entry) => entry.kind === "tool" && entry.filePath === change.path)
.pop();
if (!tool) {
nova.workspace.openFile(change.path);
return;
}
const preview = diff.previewForTool(tool.name, tool.input);
if (preview) {
diff.showPreview(preview);
} else {
nova.workspace.openFile(change.path);
}
}
}
const controller = new Controller();
exports.activate = function () {
log("activating");
try {
controller.activate();
} catch (err) {
warn("activation failed:", err);
nova.workspace.showErrorMessage("The Claude extension failed to start: " + (err.message || err));
}
};
exports.deactivate = function () {
controller.dispose();
};
+938
View File
@@ -0,0 +1,938 @@
//
// permissions.js — answering the CLI's `can_use_tool` requests.
//
// Every prompt is registered in the sidebar first and only then surfaced as a
// panel. Dismissing the panel therefore does not strand the session: the
// request stays in "Needs Approval" until it is answered from the context
// menu, or the CLI withdraws it.
//
// Two tools are their own interaction surface rather than a yes/no gate, and
// get purpose-built handling:
//
// AskUserQuestion — the host collects the answers and returns them by
// rewriting the tool input (`updatedInput.answers`).
// ExitPlanMode — the host shows the plan and either approves it (moving
// the session out of plan mode) or sends back feedback.
//
const { log, oneLine, stripAnsi, warn, conf, basename } = require("./util.js");
const { summarizeTool, friendlyToolName, planTitle } = require("./session.js");
const diff = require("./diff.js");
const ALLOW = "Allow";
const ALLOW_ALWAYS = "Allow for Session";
const DENY = "Deny";
const REVIEW = "Review Change…";
const OTHER = "Other…";
const FEEDBACK_HEADER =
"<!-- Feedback on Claude's plan.\n" +
" Write below, then choose “Send Feedback” in the Claude sidebar.\n" +
" Editing the plan itself works too — approving sends your version. -->\n\n";
/**
* The actions a pending request offers, rendered as rows under it in the
* sidebar. Ids are handed straight back to `decide()`.
*/
function actionsFor(approval) {
const name = approval.request.tool_name;
if (name === "ExitPlanMode") {
return [
{ id: "approve-auto", label: "Approve — auto-accept edits" },
{ id: "approve-manual", label: "Approve — ask before each edit" },
approval.feedbackEditor
? { id: "send-feedback", label: "Send Feedback" }
: { id: "write-feedback", label: "Write Feedback…" },
{ id: "show", label: "Open Plan" },
];
}
if (name === "AskUserQuestion") {
return [
{ id: "answer", label: "Answer…" },
{ id: "deny", label: "Dismiss" },
];
}
if (approval.proposalEditor) {
const actions = [
{ id: "apply", label: "Apply Change" },
{ id: "deny", label: "Reject" },
{ id: "open-proposal", label: "Open Proposal" },
{ id: "review", label: "Show Diff" },
];
if (!approval.request.suppress_always_allow_rule) {
actions.push({ id: "always", label: "Allow for Session" });
}
return actions;
}
const actions = [{ id: "allow", label: "Allow" }];
if (!approval.request.suppress_always_allow_rule) {
actions.push({ id: "always", label: "Allow for Session" });
}
if (approval.preview) {
actions.push({ id: "review", label: "Review Change…" });
}
actions.push({ id: "deny", label: "Deny" });
return actions;
}
/** Quote a passage so it reads as a citation in the message back to Claude. */
function quote(text) {
return String(text)
.split("\n")
.map((line) => "> " + line)
.join("\n");
}
/** Promise wrappers around Nova's callback-style panels. */
function choicePalette(choices, placeholder) {
return new Promise((resolve) => {
nova.workspace.showChoicePalette(choices, { placeholder: placeholder }, (choice, index) => {
resolve(index === null || index === undefined ? null : index);
});
});
}
function inputPanel(message, options) {
return new Promise((resolve) => {
nova.workspace.showInputPanel(message, options || {}, (value) => resolve(value));
});
}
function actionPanel(message, buttons) {
return new Promise((resolve) => {
nova.workspace.showActionPanel(message, { buttons: buttons }, (index) => {
resolve(index === null || index === undefined ? null : index);
});
});
}
class PermissionBroker {
constructor(session, transcript) {
this.session = session;
this.transcript = transcript;
}
/**
* Handle one `can_use_tool` request.
* Resolves with a PermissionResult once the user decides.
*/
handle(request, requestId) {
return new Promise((resolve) => {
const summary = summarizeTool(request.tool_name, request.input);
const preview = diff.previewForTool(request.tool_name, request.input);
const approval = {
requestId: requestId,
request: request,
summary: summary,
preview: preview,
settled: false,
resolve: (result) => {
if (approval.settled) return;
approval.settled = true;
this.session.removeApproval(requestId);
resolve(result);
},
};
this.session.addApproval(approval);
this.transcript.update();
this._present(approval);
});
}
/** Route a request to the surface that fits it. */
_present(approval) {
const name = approval.request.tool_name;
if (name === "AskUserQuestion") {
this._askQuestions(approval);
return;
}
if (name === "ExitPlanMode") {
this._reviewPlan(approval);
return;
}
// A file change gets an editable proposal rather than a yes/no panel,
// so it can be amended before it lands.
if (approval.preview && conf("claudenova.reviewEdits", "editor") === "editor") {
this._reviewChange(approval);
return;
}
if (approval.request.requires_user_interaction) {
approval.resolve({
behavior: "deny",
message:
`${name} needs its own interactive prompt, which Nova cannot display. ` +
"Run it from `claude` in a terminal, or take a different approach.",
});
return;
}
this._prompt(approval);
}
/** Withdraw a prompt the CLI no longer needs an answer for. */
cancel(requestId) {
const approval = this.session.findApproval(requestId);
if (!approval) return;
approval.settled = true;
this.session.removeApproval(requestId);
log("permission request withdrawn:", requestId);
}
/** Answer from the sidebar: "allow" | "always" | "deny" | "review". */
async decide(approval, choice) {
if (!approval || approval.settled) return;
// Plan actions, chosen from the sidebar rather than a panel.
switch (choice) {
case "approve-auto":
await this._approvePlan(approval, "acceptEdits");
return;
case "approve-manual":
await this._approvePlan(approval, "default");
return;
case "write-feedback":
await this.writeFeedback(approval);
return;
case "send-feedback":
await this.sendFeedback(approval);
return;
case "show":
await this._showPlan(approval.request.input || {});
return;
case "answer":
await this._askQuestions(approval);
return;
case "apply":
await this.applyChange(approval);
return;
case "open-proposal":
if (approval.proposalPath) await nova.workspace.openFile(approval.proposalPath);
return;
default:
break;
}
// These two carry their own decision surface; a bare allow would skip
// the mode change a plan needs, and a bare deny would drop an answer.
const name = approval.request.tool_name;
if (name === "AskUserQuestion" || name === "ExitPlanMode") {
if (choice === "deny") {
approval.resolve({
behavior: "deny",
message: "The user declined this in Nova.",
});
return;
}
this._present(approval);
return;
}
switch (choice) {
case "allow":
approval.resolve({ behavior: "allow" });
return;
case "always":
approval.resolve(this._allowAlways(approval.request));
return;
case "deny":
approval.resolve({
behavior: "deny",
message: "The user declined this action in Nova.",
});
return;
case "review":
await this.review(approval);
return;
default:
return;
}
}
/**
* Re-open whatever surface this request needs. For a plain tool that is the
* change preview; for questions and plans it is the flow itself.
*/
async review(approval) {
if (approval.settled) return;
const name = approval.request.tool_name;
if (name === "AskUserQuestion" || name === "ExitPlanMode") {
this._present(approval);
return;
}
if (approval.preview) {
await diff.showPreview(approval.preview);
} else if (approval.request.input && approval.request.input.file_path) {
await nova.workspace.openFile(approval.request.input.file_path);
} else {
nova.workspace.showInformativeMessage(
"There is nothing to preview for this request:\n\n" +
oneLine(JSON.stringify(approval.request.input || {}), 400)
);
}
if (!approval.settled) this._prompt(approval);
}
// -- AskUserQuestion ---------------------------------------------------
/**
* Ask each question in turn and hand the answers back as `updatedInput`.
* The CLI feeds them to the tool, which reports them to Claude.
*/
async _askQuestions(approval) {
const input = approval.request.input || {};
const questions = Array.isArray(input.questions) ? input.questions : [];
if (questions.length === 0) {
approval.resolve({ behavior: "allow" });
return;
}
const answers = {};
for (const question of questions) {
const answer = question.multiSelect
? await this._askMultiple(question)
: await this._askOne(question);
if (answer === null) {
approval.resolve({
behavior: "deny",
message: "The user dismissed the question without answering.",
});
return;
}
answers[question.question] = answer;
}
approval.resolve({
behavior: "allow",
updatedInput: Object.assign({}, input, { answers: answers }),
});
}
_optionLabels(question) {
return (question.options || []).map((option) => {
const description = option.description ? `${oneLine(option.description, 70)}` : "";
return `${option.label}${description}`;
});
}
async _askOne(question) {
const choices = this._optionLabels(question).concat([OTHER]);
const index = await choicePalette(choices, oneLine(question.question, 90));
if (index === null) return null;
if (index === choices.length - 1) {
const custom = await inputPanel(question.question, {
label: question.header || "Answer",
placeholder: "Your answer",
prompt: "Answer",
});
if (custom === null || !custom.trim()) return null;
return custom.trim();
}
return question.options[index].label;
}
async _askMultiple(question) {
const chosen = new Set();
for (;;) {
const choices = (question.options || []).map((option, i) => {
const mark = chosen.has(i) ? "✓ " : " ";
const description = option.description ? `${oneLine(option.description, 60)}` : "";
return `${mark}${option.label}${description}`;
});
choices.push(chosen.size > 0 ? `Done — ${chosen.size} selected` : OTHER);
const index = await choicePalette(
choices,
`${oneLine(question.question, 70)} (choose any)`
);
if (index === null) return null;
if (index === choices.length - 1) {
if (chosen.size > 0) break;
const custom = await inputPanel(question.question, {
label: question.header || "Answer",
placeholder: "Your answer",
prompt: "Answer",
});
if (custom === null || !custom.trim()) return null;
return custom.trim();
}
if (chosen.has(index)) chosen.delete(index);
else chosen.add(index);
}
return Array.from(chosen)
.sort((a, b) => a - b)
.map((i) => question.options[i].label)
.join(", ");
}
// -- ExitPlanMode ------------------------------------------------------
/**
* Show the plan, then either approve it — which also chooses how the rest
* of the session handles edits — or send Claude back to planning.
*
* The plan opens as an ordinary editable document, and edits made there are
* the primary way to give feedback: an approved plan is handed back through
* `updatedInput.plan`, which the CLI writes to the plan file, so the plan of
* record becomes the user's version rather than Claude's draft.
*/
async _reviewPlan(approval) {
const input = approval.request.input || {};
await this._showPlan(input);
// A notification rather than a panel: it does not block the window, so
// the plan stays scrollable and editable while the decision is open,
// and the same choices sit under the request in the sidebar.
const request = new NotificationRequest("claudenova-plan-" + approval.requestId);
request.title = "Claude finished planning";
request.body =
(planTitle(input.plan) || "The plan is ready to review.") +
"\n\nEdit the plan in its tab to change it — approving sends your version back.";
request.actions = ["Approve", "Auto-accept edits", "Write Feedback…"];
let response = null;
try {
response = await nova.notifications.add(request);
} catch (err) {
warn("could not post the plan notification:", err);
await this._reviewPlanFallback(approval);
return;
}
if (!response || response.actionIdx === null || response.actionIdx === undefined) {
// Dismissed — the request stays in the sidebar with its actions.
this.transcript.update();
return;
}
if (response.actionIdx === 2) {
await this.writeFeedback(approval);
return;
}
await this._approvePlan(approval, response.actionIdx === 1 ? "acceptEdits" : "default");
}
/** Panel version, for when notifications are unavailable. */
async _reviewPlanFallback(approval) {
const buttons = [
"Approve — ask before each edit",
"Approve — auto-accept edits",
"Write Feedback…",
];
const index = await actionPanel(
"Claude finished planning.\n\n" +
(planTitle((approval.request.input || {}).plan) || "The plan is ready to review."),
buttons
);
if (index === null) {
this.transcript.update();
return;
}
if (index === 2) {
await this.writeFeedback(approval);
return;
}
await this._approvePlan(approval, index === 1 ? "acceptEdits" : "default");
}
/**
* Approve the plan, carrying any edits the user made in its tab, and set
* how the rest of the session handles edits.
*/
async _approvePlan(approval, mode) {
if (approval.settled) return;
const input = approval.request.input || {};
const proposed = String(input.plan || "");
const current = this._currentPlanText(input);
const edited = current.trim() !== proposed.trim() && current.trim().length > 0;
this.session.permissionMode = mode;
this.session.changed();
const result = {
behavior: "allow",
updatedPermissions: [{ type: "setMode", mode: mode, destination: "session" }],
};
if (edited) {
result.updatedInput = Object.assign({}, input, { plan: current });
this.session.addNotice("Approved your edited version of the plan.", "info");
}
approval.resolve(result);
}
/**
* Open a scratch document for feedback. Nova has no multi-line field an
* extension can put in a sidebar, so the editor itself is the text box —
* write as much as you like, then choose Send Feedback.
*/
async writeFeedback(approval) {
if (approval.settled) return;
if (approval.feedbackEditor && !approval.feedbackEditor.document.isClosed) {
await nova.workspace.openFile(approval.feedbackEditor.document.uri);
this.session.changed();
return;
}
try {
const editor = await nova.workspace.openNewTextDocument({
content: FEEDBACK_HEADER,
syntax: "markdown",
line: 5,
});
if (editor) {
approval.feedbackEditor = editor;
this.session.addNotice(
"Write your feedback in the new tab, then choose Send Feedback in the Claude sidebar.",
"info"
);
this.session.changed();
return;
}
} catch (err) {
warn("could not open a feedback document:", err);
}
// No scratch tab available — fall back to the one-line panel.
const notes = await inputPanel("What should Claude change about the plan?", {
label: "Notes",
placeholder: "Your feedback",
prompt: "Send",
});
if (notes === null) return;
this._sendFeedbackText(approval, notes);
}
/** Read the feedback document and send it back to Claude. */
async sendFeedback(approval) {
if (approval.settled) return;
let text = "";
const editor = approval.feedbackEditor;
if (editor && editor.document && !editor.document.isClosed) {
const document = editor.document;
text = document.getTextInRange(new Range(0, document.length));
const end = text.indexOf("-->");
if (text.trimStart().startsWith("<!--") && end >= 0) {
text = text.slice(end + 3);
}
}
this._sendFeedbackText(approval, text);
}
/** Combine notes, a highlighted passage, and plan edits into one reply. */
_sendFeedbackText(approval, notes) {
const input = approval.request.input || {};
const proposed = String(input.plan || "");
const current = this._currentPlanText(input);
const edited = current.trim() !== proposed.trim() && current.trim().length > 0;
const highlighted = this._planSelection(input);
const trimmed = String(notes || "").trim();
const parts = ["The user is not ready to proceed and wants to keep planning."];
if (highlighted) {
parts.push("", "They highlighted this part of the plan:", "", quote(highlighted));
}
if (trimmed) {
parts.push("", "Their feedback:", "", trimmed);
}
if (edited) {
parts.push(
"",
"They also edited the plan directly. This is their version — treat it as authoritative:",
"",
current.trim()
);
}
if (!highlighted && !trimmed && !edited) {
parts.push("Revise the plan before proceeding.");
}
approval.resolve({ behavior: "deny", message: parts.join("\n") });
}
/**
* The plan as it stands: the open document (which may hold unsaved edits),
* else the file on disk, else what Claude proposed.
*/
_currentPlanText(input) {
const path = input.planFilePath;
if (path) {
const editor = (nova.workspace.textEditors || []).find(
(candidate) => candidate.document && candidate.document.path === path
);
if (editor) {
return editor.document.getTextInRange(new Range(0, editor.document.length));
}
const onDisk = diff.readFile(path);
if (onDisk !== null) return onDisk;
}
return String(input.plan || "");
}
/** Text the user highlighted in the plan document, if any. */
_planSelection(input) {
const editor = nova.workspace.activeTextEditor;
if (!editor || !editor.document) return null;
const path = input.planFilePath;
if (path && editor.document.path !== path) return null;
const range = editor.selectedRange;
if (!range || range.empty) return null;
const text = editor.document.getTextInRange(range).trim();
return text || null;
}
async _showPlan(input) {
const path = input.planFilePath;
if (path && nova.fs.access(path, nova.fs.R_OK)) {
try {
await nova.workspace.openFile(path);
return;
} catch (err) {
warn("could not open the plan file:", err);
}
}
if (!input.plan) return;
try {
await nova.workspace.openNewTextDocument({
content: String(input.plan),
syntax: "markdown",
});
} catch (err) {
warn("could not show the plan:", err);
nova.workspace.showInformativeMessage(oneLine(input.plan, 800));
}
}
// -- editable change proposals -----------------------------------------
/**
* Open the file as it would be after the change, and let ordinary editor
* gestures settle it: Save applies, Close rejects. Anything the user edits
* in that tab is what gets written — the change can be amended, not just
* accepted or refused.
*/
async _reviewChange(approval) {
const preview = approval.preview;
const path = await this._writeProposal(approval, preview);
if (!path) {
// No scratch file — fall back to the panel with a diff.
this._prompt(approval);
return;
}
let editor = null;
try {
editor = await nova.workspace.openFile(path);
} catch (err) {
warn("could not open the proposed change:", err);
}
if (!editor) {
this._prompt(approval);
return;
}
approval.proposalEditor = editor;
this.session.changed();
// Save applies, close rejects — the same gestures the editor already
// trains, and the reason the tab is a real file rather than a scratch
// document (an untitled document cannot be saved without a dialog).
const disposables = [];
const cleanup = () => {
for (const d of disposables) {
try {
d.dispose();
} catch (_) {
/* already disposed */
}
}
};
disposables.push(
editor.onDidSave(() => {
if (approval.settled) return;
cleanup();
this.applyChange(approval);
})
);
disposables.push(
editor.onDidDestroy(() => {
if (approval.settled) return;
cleanup();
approval.resolve({
behavior: "deny",
message: "The user closed the proposed change without applying it.",
});
this.session.addNotice(`Rejected the change to ${basename(preview.path)}.`, "info");
})
);
const request = new NotificationRequest("claudenova-change-" + approval.requestId);
request.title = preview.title;
request.body =
`+${preview.additions} ${preview.deletions}\n\n` +
"Edit the proposal if you want changes, then save to apply it. " +
"Closing the tab rejects it.";
request.actions = ["Apply", "Reject", "Show Diff"];
let response = null;
try {
response = await nova.notifications.add(request);
} catch (err) {
warn("could not post the change notification:", err);
return; // the tab and its gestures are still live
}
if (approval.settled) return;
if (response && response.actionIdx === 0) {
cleanup();
await this.applyChange(approval);
} else if (response && response.actionIdx === 1) {
cleanup();
approval.resolve({
behavior: "deny",
message: "The user rejected the proposed change in Nova.",
});
} else if (response && response.actionIdx === 2) {
await diff.showPreview(preview);
}
}
/** Write the proposed file contents somewhere Nova can open and save. */
async _writeProposal(approval, preview) {
try {
// One directory per request: two files with the same name can be
// under review at once, and the tab should still read `index.js`.
const root = nova.path.join(
nova.workspace.path
? nova.extension.workspaceStoragePath
: nova.extension.globalStoragePath,
"proposed"
);
nova.fs.mkdir(root);
const directory = nova.path.join(root, String(approval.requestId).slice(-8));
nova.fs.mkdir(directory);
// Keep the extension so the proposal is syntax-highlighted.
const path = nova.path.join(directory, basename(preview.path));
const file = nova.fs.open(path, "w");
file.write(preview.after);
file.close();
approval.proposalPath = path;
return path;
} catch (err) {
warn("could not stage the proposed change:", err);
return null;
}
}
/** Apply the proposal, carrying any edits the user made to it. */
async applyChange(approval) {
if (approval.settled) return;
const preview = approval.preview;
const current = this._proposalText(approval);
const amended = current !== null && current !== preview.after;
const result = { behavior: "allow" };
if (amended) {
const updated = this._inputForContent(approval.request, preview, current);
if (updated) {
result.updatedInput = updated;
this.session.addNotice(
`Applied your amended version of ${basename(preview.path)}.`,
"info"
);
} else {
warn("could not express the amended change; applying Claude's version");
}
}
approval.resolve(result);
}
/**
* Rewrite the tool input so it produces `content`.
*
* A whole-file replacement is used rather than trying to recover a minimal
* edit: the old text is unique in the file by construction, so it always
* matches, and the result is exactly what the user saw.
*/
_inputForContent(request, preview, content) {
const input = request.input || {};
if (request.tool_name === "Write") {
return Object.assign({}, input, { content: content });
}
if (!preview.exists || typeof preview.before !== "string") return null;
if (request.tool_name === "Edit" || request.tool_name === "Update") {
return Object.assign({}, input, {
old_string: preview.before,
new_string: content,
replace_all: false,
});
}
if (request.tool_name === "MultiEdit") {
return Object.assign({}, input, {
edits: [{ old_string: preview.before, new_string: content, replace_all: false }],
});
}
return null;
}
/** The proposal as it stands, preferring unsaved editor content. */
_proposalText(approval) {
const editor = approval.proposalEditor;
if (editor && editor.document && !editor.document.isClosed) {
const document = editor.document;
return document.getTextInRange(new Range(0, document.length));
}
if (approval.proposalPath) {
return diff.readFile(approval.proposalPath);
}
return null;
}
// -- plain tool approvals ----------------------------------------------
_allowAlways(request) {
return {
behavior: "allow",
updatedPermissions: [
{
type: "addRules",
rules: [{ toolName: request.tool_name }],
behavior: "allow",
destination: "session",
},
],
};
}
_message(approval) {
const request = approval.request;
const lines = [];
const title = request.title || `Claude wants to use ${friendlyToolName(request.tool_name)}`;
lines.push(title);
if (approval.summary) {
lines.push("");
lines.push(approval.summary);
}
if (approval.preview) {
const { additions, deletions } = approval.preview;
lines.push("");
lines.push(`${approval.preview.title} · +${additions} ${deletions}`);
}
const reason = stripAnsi(request.decision_reason || request.description || "");
if (reason) {
lines.push("");
lines.push(oneLine(reason, 300));
}
if (request.blocked_path) {
lines.push("");
lines.push(`Outside the workspace: ${request.blocked_path}`);
}
return lines.join("\n");
}
_prompt(approval) {
const buttons = [ALLOW];
if (!approval.request.suppress_always_allow_rule) {
buttons.push(ALLOW_ALWAYS);
}
if (approval.preview) {
buttons.push(REVIEW);
}
buttons.push(DENY);
nova.workspace.showActionPanel(
this._message(approval),
{ buttons: buttons },
(index) => {
if (index === null || index === undefined) {
// Dismissed — leave it pending in the sidebar.
this.transcript.update();
return;
}
const choice = buttons[index];
if (choice === ALLOW) this.decide(approval, "allow");
else if (choice === ALLOW_ALWAYS) this.decide(approval, "always");
else if (choice === REVIEW) this.decide(approval, "review");
else this.decide(approval, "deny");
}
);
}
}
module.exports = { PermissionBroker, actionsFor };
+482
View File
@@ -0,0 +1,482 @@
//
// session.js — the conversation model.
//
// Consumes messages from the CLI and maintains everything the UI renders:
// the ordered transcript, tool activity, changed files, pending approvals,
// and running cost. Views subscribe to `onChange`.
//
const { Signal, oneLine, relativize, log } = require("./util.js");
const EDITING_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit", "Update"];
// Tools whose name is an implementation detail rather than something to show.
const TOOL_LABELS = {
AskUserQuestion: "Question",
ExitPlanMode: "Plan",
EnterPlanMode: "Plan mode",
TodoWrite: "Todos",
};
function friendlyToolName(name) {
return TOOL_LABELS[name] || name;
}
/** The first heading or line of a plan, for use as its title. */
function planTitle(plan) {
if (!plan) return "";
for (const line of String(plan).split("\n")) {
const trimmed = line.replace(/^#+\s*/, "").trim();
if (trimmed) return trimmed;
}
return "";
}
/** A one-line human summary of a tool call, used in the sidebar and prompts. */
function summarizeTool(name, input) {
const args = input || {};
switch (name) {
case "Read":
case "Edit":
case "Write":
case "MultiEdit":
case "NotebookEdit":
return relativize(args.file_path || args.notebook_path || "");
case "Bash":
case "BashOutput":
return oneLine(args.command || args.description || "", 100);
case "Glob":
return args.pattern || "";
case "Grep":
return oneLine(args.pattern || "", 60) + (args.path ? ` in ${relativize(args.path)}` : "");
case "WebFetch":
return args.url || "";
case "WebSearch":
return oneLine(args.query || "", 80);
case "Task":
return oneLine(args.description || args.subagent_type || "", 80);
case "TodoWrite":
return Array.isArray(args.todos) ? `${args.todos.length} items` : "";
case "AskUserQuestion": {
const questions = Array.isArray(args.questions) ? args.questions : [];
if (questions.length === 0) return "";
return questions.length === 1
? oneLine(questions[0].question, 70)
: `${questions.length} questions`;
}
case "ExitPlanMode":
case "EnterPlanMode":
return oneLine(planTitle(args.plan) || "ready for review", 70);
default:
break;
}
if (args.file_path) return relativize(args.file_path);
if (args.path) return relativize(args.path);
if (args.command) return oneLine(args.command, 100);
if (args.query) return oneLine(args.query, 80);
const keys = Object.keys(args);
if (keys.length === 0) return "";
return oneLine(JSON.stringify(args), 80);
}
/** The file a tool call touches, when it touches exactly one. */
function toolFilePath(name, input) {
if (!input) return null;
if (EDITING_TOOLS.indexOf(name) >= 0 || name === "Read") {
return input.file_path || input.notebook_path || null;
}
return null;
}
function isEditingTool(name) {
return EDITING_TOOLS.indexOf(name) >= 0;
}
class Session {
constructor() {
this.onChange = new Signal();
this.reset();
}
reset() {
this.status = "idle";
this.statusDetail = null;
this.sessionId = null;
this.model = null;
this.permissionMode = null;
this.cwd = null;
this.tools = [];
this.slashCommands = [];
this.entries = [];
this.toolsById = new Map();
this.assistantByMessageId = new Map();
this.approvals = [];
this.changedFiles = new Map();
this.usage = {
costUSD: null,
inputTokens: null,
outputTokens: null,
durationMs: null,
numTurns: 0,
};
this.liveAssistant = null;
this.lastError = null;
this.startedAt = null;
}
// -- mutation ----------------------------------------------------------
setStatus(status, detail) {
this.status = status;
this.statusDetail = detail || null;
this.changed();
}
changed() {
this.onChange.emit(this);
}
addEntry(entry) {
entry.at = entry.at || Date.now();
this.entries.push(entry);
return entry;
}
addUserTurn(text, context) {
this.addEntry({ kind: "user", text: text, context: context || null });
this.usage.numTurns += 1;
this.setStatus("thinking");
return this.entries[this.entries.length - 1];
}
addNotice(text, tone) {
this.addEntry({ kind: "notice", text: text, tone: tone || "info" });
this.changed();
}
// -- incoming CLI messages --------------------------------------------
handleMessage(message) {
switch (message.type) {
case "system":
this._handleSystem(message);
break;
case "rate_limit_event":
return; // advisory only
case "assistant":
this._handleAssistant(message);
break;
case "user":
this._handleUser(message);
break;
case "stream_event":
this._handleStreamEvent(message);
break;
case "result":
this._handleResult(message);
break;
default:
log("unhandled message type:", message.type);
return;
}
this.changed();
}
_handleSystem(message) {
if (message.subtype === "init") {
this.sessionId = message.session_id || this.sessionId;
this.model = message.model || this.model;
this.permissionMode = message.permissionMode || message.permission_mode || this.permissionMode;
this.cwd = message.cwd || this.cwd;
this.tools = message.tools || [];
this.slashCommands = message.slash_commands || [];
this.startedAt = this.startedAt || Date.now();
if (this.status === "starting") this.setStatus("idle");
return;
}
if (message.subtype === "compact_boundary") {
this.addEntry({ kind: "notice", text: "Conversation history was compacted.", tone: "info" });
return;
}
if (message.subtype === "permission_denied") {
const tool = message.tool_name || "A tool";
this.addEntry({
kind: "notice",
text: `${tool} was denied${message.reason ? ": " + message.reason : "."}`,
tone: "error",
});
return;
}
// Advisory chatter with nothing to render.
if (
message.subtype === "status" ||
message.subtype === "thinking_tokens" ||
message.subtype === "commands_changed" ||
message.subtype === "mcp_status"
) {
return;
}
if (message.subtype === "error" || message.is_error) {
const text = message.message || message.error || "Claude Code reported an error.";
this.lastError = text;
this.addEntry({ kind: "notice", text: text, tone: "error" });
}
}
/**
* The entry that collects one assistant message.
*
* A single message id arrives more than once: the CLI emits an `assistant`
* message per completed content block (thinking, then text), and streaming
* adds deltas for the same id. They all belong to one bubble.
*/
_assistantEntry(messageId) {
if (messageId && this.assistantByMessageId.has(messageId)) {
return this.assistantByMessageId.get(messageId);
}
const entry = this.addEntry({
kind: "assistant",
messageId: messageId || null,
blocks: [],
streamed: false,
});
if (messageId) this.assistantByMessageId.set(messageId, entry);
return entry;
}
_handleAssistant(message) {
const payload = message.message || {};
const blocks = Array.isArray(payload.content) ? payload.content : [];
const entry = this._assistantEntry(payload.id);
// Keep pointing at this entry: the CLI sends one `assistant` message
// per completed block, and further blocks of the same message may
// still be streaming in behind it.
this.liveAssistant = entry;
for (const block of blocks) {
if (block.type === "tool_use") {
this._registerToolUse(block, message.parent_tool_use_id);
continue;
}
// When the text already arrived as deltas, the authoritative copy
// would duplicate it; the streamed blocks are the same content.
if (entry.streamed) continue;
if (block.type === "text") {
entry.blocks.push({ type: "text", text: block.text || "" });
} else if (block.type === "thinking") {
entry.blocks.push({ type: "thinking", text: block.thinking || "" });
}
}
if (this.status === "thinking") this.setStatus("working");
}
_registerToolUse(block, parentToolUseId) {
const name = block.name || "Tool";
const input = block.input || {};
const entry = this.addEntry({
kind: "tool",
id: block.id,
name: name,
input: input,
summary: summarizeTool(name, input),
filePath: toolFilePath(name, input),
parentToolUseId: parentToolUseId || null,
state: "running",
result: null,
});
this.toolsById.set(block.id, entry);
this.setStatus("working", `${name}${entry.summary ? " · " + entry.summary : ""}`);
}
_handleUser(message) {
const payload = message.message || {};
const content = payload.content;
if (typeof content === "string") {
if (message.isSynthetic) {
this.addEntry({ kind: "notice", text: content, tone: "info" });
}
return;
}
if (!Array.isArray(content)) return;
for (const block of content) {
if (block.type !== "tool_result") continue;
const tool = this.toolsById.get(block.tool_use_id);
if (!tool) continue;
tool.state = block.is_error ? "error" : "done";
tool.result = this._resultText(block.content);
// Only successful edits changed the file on disk. A denied or
// failed write left it untouched, so it does not belong in the
// list of things to review.
if (!block.is_error && isEditingTool(tool.name) && tool.filePath) {
this._noteChangedFile(tool.filePath, tool.name, "changed", message.tool_use_result);
}
}
}
_resultText(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((b) => b && b.type === "text")
.map((b) => b.text)
.join("\n");
}
_handleStreamEvent(message) {
const event = message.event;
if (!event) return;
if (event.type === "message_start") {
const id = event.message && event.message.id;
const entry = this._assistantEntry(id);
entry.streamed = true;
entry.streaming = true;
this.liveAssistant = entry;
return;
}
if (!this.liveAssistant) return;
if (event.type === "content_block_start") {
const block = event.content_block || {};
if (block.type === "text") {
this.liveAssistant.blocks[event.index] = { type: "text", text: block.text || "" };
} else if (block.type === "thinking") {
this.liveAssistant.blocks[event.index] = { type: "thinking", text: block.thinking || "" };
}
return;
}
if (event.type === "content_block_delta") {
const delta = event.delta || {};
const target = this.liveAssistant.blocks[event.index];
if (delta.type === "text_delta") {
if (target && target.type === "text") target.text += delta.text || "";
else this.liveAssistant.blocks[event.index] = { type: "text", text: delta.text || "" };
} else if (delta.type === "thinking_delta") {
if (target && target.type === "thinking") target.text += delta.thinking || "";
else this.liveAssistant.blocks[event.index] = { type: "thinking", text: delta.thinking || "" };
}
return;
}
if (event.type === "message_stop") {
if (this.liveAssistant) this.liveAssistant.streaming = false;
}
}
_handleResult(message) {
this.usage.costUSD = typeof message.total_cost_usd === "number" ? message.total_cost_usd : this.usage.costUSD;
this.usage.durationMs = message.duration_ms || this.usage.durationMs;
this.usage.numTurns = message.num_turns || this.usage.numTurns;
const usage = message.usage || {};
if (typeof usage.input_tokens === "number") {
this.usage.inputTokens =
usage.input_tokens + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
}
if (typeof usage.output_tokens === "number") {
this.usage.outputTokens = usage.output_tokens;
}
if (message.is_error) {
this.lastError = message.result || "The turn ended with an error.";
this.addEntry({ kind: "notice", text: this.lastError, tone: "error" });
this.setStatus("error", this.lastError);
} else {
this.setStatus("idle");
}
if (this.liveAssistant) this.liveAssistant.streaming = false;
this.liveAssistant = null;
this.assistantByMessageId.clear();
}
// -- approvals ---------------------------------------------------------
addApproval(approval) {
this.approvals.push(approval);
this.setStatus("waiting", `${approval.request.tool_name} needs approval`);
return approval;
}
removeApproval(requestId) {
const index = this.approvals.findIndex((a) => a.requestId === requestId);
if (index >= 0) this.approvals.splice(index, 1);
if (this.approvals.length === 0 && this.status === "waiting") {
this.setStatus("working");
} else {
this.changed();
}
}
findApproval(requestId) {
return this.approvals.find((a) => a.requestId === requestId) || null;
}
// -- changed files -----------------------------------------------------
_noteChangedFile(path, tool, state, toolResult) {
const existing = this.changedFiles.get(path) || {
path: path,
edits: 0,
tool: tool,
additions: null,
deletions: null,
};
existing.tool = tool;
existing.edits += 1;
if (toolResult && typeof toolResult === "object") {
if (typeof toolResult.additions === "number") existing.additions = toolResult.additions;
if (typeof toolResult.deletions === "number") existing.deletions = toolResult.deletions;
}
this.changedFiles.set(path, existing);
}
clearChangedFiles() {
this.changedFiles.clear();
this.changed();
}
get isBusy() {
return this.status === "thinking" || this.status === "working" || this.status === "waiting";
}
}
module.exports = {
Session,
summarizeTool,
toolFilePath,
isEditingTool,
friendlyToolName,
planTitle,
};
+110
View File
@@ -0,0 +1,110 @@
//
// sessions-store.js — reading Claude Code's own session history.
//
// Claude Code writes one JSONL transcript per session under
// ~/.claude/projects/<slug>/<session-id>.jsonl, where the slug is the project
// directory with path separators and dots flattened to dashes. Listing that
// directory is enough to offer a "resume" picker without shelling out to the
// interactive one.
//
const { log, oneLine } = require("./util.js");
function projectSlug(path) {
return String(path).replace(/[/.]/g, "-");
}
function projectDirectory(workspacePath) {
const home = nova.environment["HOME"];
if (!home || !workspacePath) return null;
return nova.path.join(home, ".claude", "projects", projectSlug(workspacePath));
}
/** First user prompt in a transcript, used as the picker label. */
function firstPrompt(filePath) {
let file = null;
try {
file = nova.fs.open(filePath, "r");
for (let i = 0; i < 40; i++) {
const line = file.readline();
if (!line) break;
let record;
try {
record = JSON.parse(line);
} catch (_) {
continue;
}
if (record.type !== "user" || !record.message) continue;
if (record.isMeta || record.isSynthetic) continue;
const content = record.message.content;
if (typeof content === "string") {
if (content.trim()) return oneLine(content, 70);
} else if (Array.isArray(content)) {
for (const block of content) {
if (block && block.type === "text" && block.text && block.text.trim()) {
return oneLine(block.text, 70);
}
}
}
}
} catch (err) {
log("could not read session transcript:", filePath, err);
} finally {
if (file) {
try {
file.close();
} catch (_) {
/* already closed */
}
}
}
return null;
}
/**
* Recent sessions for a workspace, newest first.
* Returns [{ id, path, modifiedAt, label }].
*/
function recentSessions(workspacePath, limit = 15) {
const directory = projectDirectory(workspacePath);
if (!directory) return [];
let names;
try {
if (!nova.fs.access(directory, nova.fs.R_OK)) return [];
names = nova.fs.listdir(directory);
} catch (err) {
log("no session history:", err);
return [];
}
const sessions = [];
for (const name of names) {
if (!name.endsWith(".jsonl")) continue;
const filePath = nova.path.join(directory, name);
const stats = nova.fs.stat(filePath);
if (!stats || !stats.isFile() || stats.size === 0) continue;
sessions.push({
id: name.slice(0, -".jsonl".length),
path: filePath,
modifiedAt: stats.mtime,
size: stats.size,
});
}
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
const recent = sessions.slice(0, limit);
for (const session of recent) {
session.label = firstPrompt(session.path) || session.id.slice(0, 8);
}
return recent;
}
module.exports = { recentSessions, projectDirectory, projectSlug };
+305
View File
@@ -0,0 +1,305 @@
//
// sidebar.js — the four sidebar sections.
//
// Session state, pending approvals, tool activity, and changed files. Each is
// a flat list; the interesting behaviour lives in the context commands that
// main.js registers against the selections here.
//
const {
throttle,
relativize,
basename,
oneLine,
modeLabel,
formatCost,
formatTokens,
} = require("./util.js");
const { friendlyToolName } = require("./session.js");
const { actionsFor } = require("./permissions.js");
const RELOAD_INTERVAL = 200;
const MAX_ACTIVITY_ROWS = 120;
const STATUS_LABEL = {
idle: "Ready",
starting: "Starting…",
thinking: "Thinking…",
working: "Working…",
waiting: "Waiting for you",
stopped: "Stopped",
error: "Error",
};
function item(name, options = {}) {
const treeItem = new TreeItem(name, TreeItemCollapsibleState.None);
if (options.descriptiveText) treeItem.descriptiveText = options.descriptiveText;
if (options.tooltip) treeItem.tooltip = options.tooltip;
if (options.image) treeItem.image = options.image;
if (options.path) treeItem.path = options.path;
if (options.command) treeItem.command = options.command;
if (options.contextValue) treeItem.contextValue = options.contextValue;
if (options.identifier) treeItem.identifier = options.identifier;
return treeItem;
}
class SessionProvider {
constructor(context) {
this.context = context;
}
getChildren(element) {
if (element) return [];
const session = this.context.session;
const auth = this.context.authState;
const rows = [];
rows.push({
kind: "status",
name: STATUS_LABEL[session.status] || session.status,
detail: session.statusDetail,
});
rows.push({ kind: "account", auth: auth });
rows.push({
kind: "model",
name: session.model || this.context.configuredModel() || "Default model",
});
rows.push({
kind: "mode",
name: session.permissionMode
? modeLabel(session.permissionMode)
: this.context.configuredPermissionMode(),
});
const cost = formatCost(session.usage.costUSD);
const input = formatTokens(session.usage.inputTokens);
const output = formatTokens(session.usage.outputTokens);
if (cost || input || output) {
rows.push({
kind: "usage",
cost: cost,
tokens: input || output ? `${input || "0"} in / ${output || "0"} out` : null,
});
}
return rows;
}
getTreeItem(element) {
switch (element.kind) {
case "status": {
const treeItem = item(element.name, {
descriptiveText: element.detail ? oneLine(element.detail, 60) : "",
image: "__builtin.action",
contextValue: "status",
});
return treeItem;
}
case "account": {
const auth = element.auth;
const signedIn = auth && auth.loggedIn;
return item(signedIn ? "Account" : "Not signed in", {
descriptiveText: this.context.describeAuth(),
command: signedIn ? "claudenova.authStatus" : "claudenova.signIn",
contextValue: "account",
tooltip: signedIn ? "Signed in through Claude Code" : "Sign in to Claude",
});
}
case "model":
return item("Model", {
descriptiveText: element.name,
command: "claudenova.setModel",
contextValue: "model",
});
case "mode":
return item("Permissions", {
descriptiveText: element.name,
command: "claudenova.setPermissionMode",
contextValue: "mode",
});
case "usage":
return item("Usage", {
descriptiveText: [element.cost, element.tokens].filter(Boolean).join(" · "),
contextValue: "usage",
});
default:
return item(String(element.kind));
}
}
getParent() {
return null;
}
}
class ApprovalsProvider {
constructor(context) {
this.context = context;
}
getChildren(element) {
if (!element) return this.context.session.approvals.slice();
// The choices for a request are rows beneath it, so a decision never
// depends on a panel being open.
if (element.request) {
return actionsFor(element).map((action) =>
Object.assign({ approval: element }, action)
);
}
return [];
}
getTreeItem(element) {
if (element.approval) {
return item(element.label, {
identifier: element.approval.requestId + ":" + element.id,
contextValue: "approval-action",
command: "claudenova.approvalAction",
tooltip: element.label,
});
}
const approval = element;
const request = approval.request;
const detail = approval.preview
? `+${approval.preview.additions} ${approval.preview.deletions}`
: oneLine(approval.summary, 50);
const treeItem = new TreeItem(
friendlyToolName(request.tool_name),
TreeItemCollapsibleState.Expanded
);
treeItem.descriptiveText = detail;
treeItem.tooltip = approval.summary || request.tool_name;
treeItem.image = "__builtin.action";
treeItem.contextValue = "approval";
treeItem.identifier = approval.requestId;
return treeItem;
}
getParent(element) {
return element && element.approval ? element.approval : null;
}
}
class ActivityProvider {
constructor(context) {
this.context = context;
}
getChildren(element) {
if (element) return [];
const tools = this.context.session.entries.filter((entry) => entry.kind === "tool");
return tools.slice(-MAX_ACTIVITY_ROWS).reverse();
}
getTreeItem(entry) {
const marks = { running: "…", done: "", error: "failed", denied: "denied" };
const detail = [oneLine(entry.summary, 48), marks[entry.state]].filter(Boolean).join(" · ");
const treeItem = item(friendlyToolName(entry.name), {
descriptiveText: detail,
tooltip: entry.summary || entry.name,
identifier: entry.id,
contextValue: entry.filePath ? "activity-file" : "activity",
});
if (entry.filePath) {
treeItem.path = entry.filePath;
treeItem.command = "claudenova.openActivityFile";
}
return treeItem;
}
getParent() {
return null;
}
}
class ChangesProvider {
constructor(context) {
this.context = context;
}
getChildren(element) {
if (element) return [];
return Array.from(this.context.session.changedFiles.values());
}
getTreeItem(change) {
const counts =
change.additions !== null || change.deletions !== null
? `+${change.additions || 0} ${change.deletions || 0}`
: null;
const detail = [counts, change.edits > 1 ? `${change.edits} edits` : null]
.filter(Boolean)
.join(" · ");
const treeItem = item(basename(change.path), {
descriptiveText: detail,
tooltip: relativize(change.path),
path: change.path,
identifier: change.path,
contextValue: "change",
command: "claudenova.openChangedFile",
});
return treeItem;
}
getParent() {
return null;
}
}
class Sidebar {
/**
* @param context an object exposing `session`, `authState`, `describeAuth()`,
* `configuredModel()` and `configuredPermissionMode()`.
*/
constructor(context) {
this.context = context;
this.sessionView = new TreeView("claude.session", { dataProvider: new SessionProvider(context) });
this.approvalsView = new TreeView("claude.approvals", { dataProvider: new ApprovalsProvider(context) });
this.activityView = new TreeView("claude.activity", { dataProvider: new ActivityProvider(context) });
this.changesView = new TreeView("claude.changes", { dataProvider: new ChangesProvider(context) });
this.views = [this.sessionView, this.approvalsView, this.activityView, this.changesView];
this.reload = throttle(() => {
for (const view of this.views) {
view.reload().catch(() => {
/* the view may not be visible yet */
});
}
}, RELOAD_INTERVAL);
}
dispose() {
this.reload.cancel();
for (const view of this.views) {
try {
view.dispose();
} catch (_) {
/* already disposed */
}
}
}
}
module.exports = { Sidebar };
+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 };
+286
View File
@@ -0,0 +1,286 @@
//
// util.js — small helpers shared across the extension.
//
const LOG_PREFIX = "[claude]";
/** Log only when the extension is running in development mode. */
function log(...args) {
if (nova.inDevMode()) {
console.log(LOG_PREFIX, ...args);
}
}
function warn(...args) {
console.warn(LOG_PREFIX, ...args);
}
/** Read a config key, preferring the workspace value when it is set. */
function conf(key, fallback) {
let value = nova.workspace.config.get(key);
if (value === null || value === undefined || value === "") {
value = nova.config.get(key);
}
if (value === null || value === undefined || value === "") {
return fallback;
}
return value;
}
// The CLI reports "default" for the mode its own flag calls "manual".
const PERMISSION_MODE_LABELS = {
default: "Ask each time",
manual: "Ask each time",
acceptEdits: "Accept edits",
plan: "Plan only",
auto: "Auto",
dontAsk: "Never ask",
bypassPermissions: "Bypass (dangerous)",
};
function modeLabel(mode) {
if (!mode) return "";
return PERMISSION_MODE_LABELS[mode] || mode;
}
/** Collapse whitespace and cut to `max` characters with an ellipsis. */
function oneLine(text, max = 80) {
if (!text) return "";
const flat = String(text).replace(/\s+/g, " ").trim();
return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
}
/** Strip ANSI escapes — the CLI may include them in human-readable reasons. */
const ANSI_PATTERN = new RegExp("\\[[0-9;]*[A-Za-z]", "g");
function stripAnsi(text) {
if (!text) return "";
return String(text).replace(ANSI_PATTERN, "");
}
/** Path relative to the workspace, when it is inside one. */
function relativize(path) {
if (!path) return "";
if (nova.workspace.path && path.startsWith(nova.workspace.path)) {
const rel = path.slice(nova.workspace.path.length).replace(/^\//, "");
return rel || nova.path.basename(path);
}
return path;
}
function basename(path) {
if (!path) return "";
return nova.path.basename(path);
}
function formatCost(usd) {
if (typeof usd !== "number" || Number.isNaN(usd)) return null;
if (usd === 0) return "$0.00";
if (usd < 0.01) return "<$0.01";
return "$" + usd.toFixed(2);
}
function formatTokens(n) {
if (typeof n !== "number" || Number.isNaN(n)) return null;
if (n < 1000) return String(n);
if (n < 1000000) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + "k";
return (n / 1000000).toFixed(1) + "M";
}
function formatDuration(ms) {
if (typeof ms !== "number" || Number.isNaN(ms)) return null;
const s = ms / 1000;
if (s < 60) return s.toFixed(1) + "s";
const m = Math.floor(s / 60);
return `${m}m ${Math.round(s - m * 60)}s`;
}
/**
* Trailing-edge throttle: coalesces bursts into one call every `delay` ms.
* The returned function has `.flush()` to run a pending call immediately.
*/
function throttle(fn, delay) {
let timer = null;
let pending = false;
let lastArgs = null;
const invoke = (args) => {
fn(...(args || []));
timer = setTimeout(() => {
timer = null;
if (pending) {
pending = false;
invoke(lastArgs);
}
}, delay);
};
const throttled = (...args) => {
lastArgs = args;
if (timer === null) {
invoke(args);
} else {
pending = true;
}
};
throttled.flush = () => {
if (!pending) return;
pending = false;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
fn(...(lastArgs || []));
};
throttled.cancel = () => {
pending = false;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
return throttled;
}
/** Minimal event emitter — keeps subscription ordering obvious. */
class Signal {
constructor() {
this.handlers = [];
}
on(handler) {
this.handlers.push(handler);
return {
dispose: () => {
const i = this.handlers.indexOf(handler);
if (i >= 0) this.handlers.splice(i, 1);
},
};
}
emit(...args) {
for (const handler of this.handlers.slice()) {
try {
handler(...args);
} catch (err) {
warn("signal handler failed:", err);
}
}
}
}
/**
* Run a command to completion and collect its output.
* Resolves with { status, stdout, stderr } — it never rejects on a non-zero exit.
*/
function runCommand(command, args, options = {}) {
return new Promise((resolve) => {
let process;
try {
process = new Process(command, {
args: args,
cwd: options.cwd || nova.workspace.path || nova.extension.globalStoragePath,
env: options.env || {},
stdio: "pipe",
shell: options.shell === true,
});
} catch (err) {
resolve({ status: -1, stdout: "", stderr: String(err) });
return;
}
const out = [];
const err = [];
let settled = false;
const settle = (status, extraErr) => {
if (settled) return;
settled = true;
resolve({
status: status,
stdout: out.join(""),
stderr: err.join("") + (extraErr || ""),
});
};
process.onStdout((line) => out.push(line));
process.onStderr((line) => err.push(line));
process.onDidExit((status) => settle(status));
if (options.timeout) {
setTimeout(() => {
if (settled) return;
try {
process.terminate();
} catch (_) {
/* already gone */
}
settle(-1, "\n[timed out]");
}, options.timeout);
}
try {
process.start();
} catch (e) {
settle(-1, String(e));
}
});
}
/**
* A writer for a process's stdin.
* Nova exposes stdin as a WritableStream that also has a convenience `write()`;
* fall back to a stream writer when it does not.
*/
function processWriter(process) {
const stdin = process.stdin;
if (!stdin) {
throw new Error("Process was started without a writable stdin.");
}
if (typeof stdin.write === "function") {
return {
write: (text) => stdin.write(text),
close: () => {
try {
if (typeof stdin.close === "function") stdin.close();
} catch (_) {
/* already closed */
}
},
};
}
const writer = stdin.getWriter();
return {
write: (text) => writer.write(text),
close: () => {
try {
writer.close();
} catch (_) {
/* already closed */
}
},
};
}
module.exports = {
log,
warn,
conf,
processWriter,
modeLabel,
oneLine,
stripAnsi,
relativize,
basename,
formatCost,
formatTokens,
formatDuration,
throttle,
Signal,
runCommand,
};