First version
This commit is contained in:
+286
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user