repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/nushell/index.ts
Rawimport { execFile } from "node:child_process";
import {
createBashToolDefinition,
type ExtensionAPI,
type ExtensionContext,
type ThemeColor,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { findNu, freshOperations } from "./runtime.ts";
import { closeDebug, dbg } from "./src/debug.ts";
import {
parseBooleanSetting,
registerSettingFlag,
resolveSetting,
type SettingDeclaration,
} from "./src/pi-ext-settings.ts";
// Precedence: --nushell, PI_NUSHELL_ENABLE, trusted project, user, false.
const ENABLE_SETTING: SettingDeclaration<boolean> = {
key: "nushell.enable",
parse: parseBooleanSetting,
default: false,
flag: {
name: "nushell",
type: "boolean",
description:
"Enable Nushell tools at startup instead of bash and powershell",
},
env: "PI_NUSHELL_ENABLE",
};
const NU_COLORS: Record<number, ThemeColor> = {
32: "syntaxString",
33: "syntaxOperator",
34: "syntaxPunctuation",
35: "syntaxVariable",
36: "syntaxFunction",
90: "syntaxVariable",
96: "syntaxKeyword",
};
// Translate Nu's fixed ANSI palette into the active Pi theme at render time.
function themeNuHighlight(
ansi: string,
theme: Parameters<
NonNullable<ReturnType<typeof createBashToolDefinition>["renderCall"]>
>[1],
): string {
let output = "";
let start = 0;
let color: ThemeColor = "toolOutput";
for (const match of ansi.matchAll(/\x1b\[([0-9;]*)m/g)) {
output += theme.fg(color, ansi.slice(start, match.index));
const codes = match[1]?.split(";").map(Number) ?? [];
const foreground = codes.findLast(
(code) => (code >= 30 && code <= 37) || (code >= 90 && code <= 97),
);
color =
foreground === 35 && codes.includes(1)
? "syntaxPunctuation"
: (NU_COLORS[foreground ?? -1] ?? "toolOutput");
start = match.index + match[0].length;
}
return output + theme.fg(color, ansi.slice(start));
}
// Nu parses source without evaluating it; only the command heading is highlighted.
const highlightCache = new WeakMap<object, { command: string; text: string }>();
function renderNuCall(
args: { command: string; timeout?: number },
theme: Parameters<
NonNullable<ReturnType<typeof createBashToolDefinition>["renderCall"]>
>[1],
context: Parameters<
NonNullable<ReturnType<typeof createBashToolDefinition>["renderCall"]>
>[2],
) {
const state = context.state;
if (context.executionStarted && state.startedAt === undefined) {
state.startedAt = Date.now();
state.endedAt = undefined;
}
const command = typeof args?.command === "string" ? args.command : "";
let cached = highlightCache.get(state);
if (context.argsComplete && cached?.command !== command) {
const entry = { command, text: command };
cached = entry;
highlightCache.set(state, entry);
const binary = command && command.length <= 16_000 ? findNu() : undefined;
if (binary) {
execFile(
binary,
[
"--no-config-file",
"--no-history",
"-c",
"$env.PI_NU_HIGHLIGHT_INPUT | nu-highlight",
],
{
env: { ...process.env, PI_NU_HIGHLIGHT_INPUT: command },
encoding: "utf8",
timeout: 1000,
maxBuffer: 128 * 1024,
windowsHide: true,
},
(error, stdout) => {
if (error || highlightCache.get(state) !== entry) return;
entry.text = stdout.trimEnd();
context.invalidate();
},
);
}
}
const highlighted = cached?.command === command ? cached.text : command;
const text =
context.lastComponent instanceof Text
? context.lastComponent
: new Text("", 0, 0);
text.setText(
`${theme.fg("toolTitle", theme.bold(" nu>"))} ${themeNuHighlight(highlighted || "...", theme)}${args?.timeout ? theme.fg("muted", ` (timeout ${args.timeout}s)`) : ""}`,
);
return text;
}
export default function nushellExtension(pi: ExtensionAPI): void {
let enabled = false;
let displaced: string[] = [];
registerSettingFlag(pi, ENABLE_SETTING);
function activate(ctx: ExtensionContext, quiet = false): void {
if (enabled) return;
if (!findNu()) {
ctx.ui.notify(
"Nushell executable not found on PATH; tools unchanged",
"warning",
);
dbg?.("tools.enable.error", { kind: "nu_not_found" });
return;
}
const available = new Set(pi.getAllTools().map((tool) => tool.name));
const missing = ["nu"].filter((name) => !available.has(name));
if (missing.length) {
ctx.ui.notify(
`Nushell tools excluded by tool allowlist: ${missing.join(", ")}; tools unchanged`,
"warning",
);
dbg?.("tools.enable.error", {
kind: "tools_excluded",
count: missing.length,
});
return;
}
const active = pi.getActiveTools();
displaced = active.filter(
(tool) => tool === "bash" || tool === "powershell",
);
pi.setActiveTools([
...active.filter((tool) => !displaced.includes(tool)),
"nu",
]);
enabled = true;
dbg?.("tools.enabled", { count: displaced.length });
if (!quiet) ctx.ui.notify("Nushell tools on: nu (fresh)", "info");
}
const freshTool = createBashToolDefinition(process.cwd(), {
operations: {
exec: (command, cwd, options) => {
const binary = findNu();
if (!binary) throw new Error("Nushell executable not found on PATH");
return freshOperations(binary).exec(command, cwd, options);
},
},
});
pi.registerTool({
...freshTool,
name: "nu",
annotations: { readOnlyHint: false },
label: " nu",
description:
"Execute Nushell commands in a fresh process in Pi's cwd. Syntax is Nushell, not Bash or PowerShell. Each call has independent variables, functions, environment, and cwd. Streams output; timeout in seconds is optional.",
promptSnippet:
"Execute independent Nushell commands with nu; each call starts fresh",
promptGuidelines: [
"nu already runs Nushell. Pass source directly; never wrap it in nu -c or ^nu -c. Prefer native commands like ls, pwd, open, mkdir, save; use ^name only for necessary external executables. Nu flags differ from GNU flags: check help <command> before guessing options. Batch short independent checks when readable.",
"Use Nushell syntax: structured pipelines, $env.NAME, (command), o> file or save. Use try/catch for expected command failures.",
],
renderCall: renderNuCall,
});
pi.registerCommand("nushell", {
description:
"Toggle fresh Nushell tools and replace active bash/powershell",
getArgumentCompletions: (prefix) => {
const matches = ["on", "off"].filter((value) => value.startsWith(prefix));
return matches.length
? matches.map((value) => ({ value, label: value }))
: null;
},
async handler(arg, ctx) {
const action = arg.trim() || (enabled ? "off" : "on");
if (action === "on") {
activate(ctx);
return;
}
if (action === "off") {
if (!enabled) return;
const active = pi.getActiveTools().filter((tool) => tool !== "nu");
pi.setActiveTools([
...active,
...displaced.filter((tool) => !active.includes(tool)),
]);
dbg?.("tools.disabled", { count: displaced.length });
displaced = [];
enabled = false;
ctx.ui.notify("Nushell tools off", "info");
return;
}
ctx.ui.notify("Usage: /nushell [on|off]", "warning");
},
});
pi.on("session_start", async (_event, ctx) => {
dbg?.("session.start");
enabled = false;
displaced = [];
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "nu"));
const setting = resolveSetting(pi, ctx, ENABLE_SETTING);
if (!setting.ok) {
ctx.ui.notify(`${setting.error}; tools unchanged`, "warning");
dbg?.("settings.invalid", { source: setting.source });
} else if (setting.value) activate(ctx, true);
});
pi.on("session_shutdown", async () => {
dbg?.("session.shutdown");
closeDebug();
});
}