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
+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,
};