287 lines
7.2 KiB
JavaScript
287 lines
7.2 KiB
JavaScript
//
|
|
// 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,
|
|
};
|