- Static site: /manifesto, /live, /hire pages - Ship-log Pi extension: calvana_ship, calvana_oops, calvana_deploy tools - Docker + nginx deploy to calvana.quikcue.com - Terminal-ish dark aesthetic, mobile responsive - Auto-updating /live page from extension state
634 lines
21 KiB
TypeScript
634 lines
21 KiB
TypeScript
/**
|
|
* Pi Pi — Meta-agent that builds Pi agents
|
|
*
|
|
* A team of domain-specific research experts (extensions, themes, skills,
|
|
* settings, TUI) operate in PARALLEL to gather documentation and patterns.
|
|
* The primary agent synthesizes their findings and WRITES the actual files.
|
|
*
|
|
* Each expert fetches fresh Pi documentation via firecrawl on first query.
|
|
* Experts are read-only researchers. The primary agent is the only writer.
|
|
*
|
|
* Commands:
|
|
* /experts — list available experts and their status
|
|
* /experts-grid N — set dashboard column count (default 3)
|
|
*
|
|
* Usage: pi -e extensions/pi-pi.ts
|
|
*/
|
|
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
import { spawn } from "child_process";
|
|
import { readdirSync, readFileSync, existsSync, mkdirSync } from "fs";
|
|
import { join, resolve } from "path";
|
|
import { applyExtensionDefaults } from "./themeMap.ts";
|
|
|
|
// ── Types ────────────────────────────────────────
|
|
|
|
interface ExpertDef {
|
|
name: string;
|
|
description: string;
|
|
tools: string;
|
|
systemPrompt: string;
|
|
file: string;
|
|
}
|
|
|
|
interface ExpertState {
|
|
def: ExpertDef;
|
|
status: "idle" | "researching" | "done" | "error";
|
|
question: string;
|
|
elapsed: number;
|
|
lastLine: string;
|
|
queryCount: number;
|
|
timer?: ReturnType<typeof setInterval>;
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────
|
|
|
|
function displayName(name: string): string {
|
|
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
}
|
|
|
|
function parseAgentFile(filePath: string): ExpertDef | null {
|
|
try {
|
|
const raw = readFileSync(filePath, "utf-8");
|
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
if (!match) return null;
|
|
|
|
const frontmatter: Record<string, string> = {};
|
|
for (const line of match[1].split("\n")) {
|
|
const idx = line.indexOf(":");
|
|
if (idx > 0) {
|
|
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
}
|
|
}
|
|
|
|
if (!frontmatter.name) return null;
|
|
|
|
return {
|
|
name: frontmatter.name,
|
|
description: frontmatter.description || "",
|
|
tools: frontmatter.tools || "read,grep,find,ls",
|
|
systemPrompt: match[2].trim(),
|
|
file: filePath,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Expert card colors ────────────────────────────
|
|
// Each expert gets a unique hue: bg fills the card interior,
|
|
// br is the matching border foreground (brighter shade of same hue).
|
|
const EXPERT_COLORS: Record<string, { bg: string; br: string }> = {
|
|
"agent-expert": { bg: "\x1b[48;2;20;30;75m", br: "\x1b[38;2;70;110;210m" }, // navy
|
|
"config-expert": { bg: "\x1b[48;2;18;65;30m", br: "\x1b[38;2;55;175;90m" }, // forest
|
|
"ext-expert": { bg: "\x1b[48;2;80;18;28m", br: "\x1b[38;2;210;65;85m" }, // crimson
|
|
"keybinding-expert": { bg: "\x1b[48;2;50;22;85m", br: "\x1b[38;2;145;80;220m" }, // violet
|
|
"prompt-expert": { bg: "\x1b[48;2;80;55;12m", br: "\x1b[38;2;215;150;40m" }, // amber
|
|
"skill-expert": { bg: "\x1b[48;2;12;65;75m", br: "\x1b[38;2;40;175;195m" }, // teal
|
|
"theme-expert": { bg: "\x1b[48;2;80;18;62m", br: "\x1b[38;2;210;55;160m" }, // rose
|
|
"tui-expert": { bg: "\x1b[48;2;28;42;80m", br: "\x1b[38;2;85;120;210m" }, // slate
|
|
"cli-expert": { bg: "\x1b[48;2;60;80;20m", br: "\x1b[38;2;160;210;55m" }, // olive/lime
|
|
};
|
|
const FG_RESET = "\x1b[39m";
|
|
const BG_RESET = "\x1b[49m";
|
|
|
|
// ── Extension ────────────────────────────────────
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
const experts: Map<string, ExpertState> = new Map();
|
|
let gridCols = 3;
|
|
let widgetCtx: any;
|
|
|
|
function loadExperts(cwd: string) {
|
|
// Pi Pi experts live in their own dedicated directory
|
|
const piPiDir = join(cwd, ".pi", "agents", "pi-pi");
|
|
|
|
experts.clear();
|
|
|
|
if (!existsSync(piPiDir)) return;
|
|
try {
|
|
for (const file of readdirSync(piPiDir)) {
|
|
if (!file.endsWith(".md")) continue;
|
|
if (file === "pi-orchestrator.md") continue;
|
|
const fullPath = resolve(piPiDir, file);
|
|
const def = parseAgentFile(fullPath);
|
|
if (def) {
|
|
const key = def.name.toLowerCase();
|
|
if (!experts.has(key)) {
|
|
experts.set(key, {
|
|
def,
|
|
status: "idle",
|
|
question: "",
|
|
elapsed: 0,
|
|
lastLine: "",
|
|
queryCount: 0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// ── Grid Rendering ───────────────────────────
|
|
|
|
function renderCard(state: ExpertState, colWidth: number, theme: any): string[] {
|
|
const w = colWidth - 2;
|
|
const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
|
|
const statusColor = state.status === "idle" ? "dim"
|
|
: state.status === "researching" ? "accent"
|
|
: state.status === "done" ? "success" : "error";
|
|
const statusIcon = state.status === "idle" ? "○"
|
|
: state.status === "researching" ? "◉"
|
|
: state.status === "done" ? "✓" : "✗";
|
|
|
|
const name = displayName(state.def.name);
|
|
const nameStr = theme.fg("accent", theme.bold(truncate(name, w)));
|
|
const nameVisible = Math.min(name.length, w);
|
|
|
|
const statusStr = `${statusIcon} ${state.status}`;
|
|
const timeStr = state.status !== "idle" ? ` ${Math.round(state.elapsed / 1000)}s` : "";
|
|
const queriesStr = state.queryCount > 0 ? ` (${state.queryCount})` : "";
|
|
const statusLine = theme.fg(statusColor, statusStr + timeStr + queriesStr);
|
|
const statusVisible = statusStr.length + timeStr.length + queriesStr.length;
|
|
|
|
const workRaw = state.question || state.def.description;
|
|
const workText = truncate(workRaw, Math.min(50, w - 1));
|
|
const workLine = theme.fg("muted", workText);
|
|
const workVisible = workText.length;
|
|
|
|
const lastRaw = state.lastLine || "";
|
|
const lastText = truncate(lastRaw, Math.min(50, w - 1));
|
|
const lastLineRendered = lastText ? theme.fg("dim", lastText) : theme.fg("dim", "—");
|
|
const lastVisible = lastText ? lastText.length : 1;
|
|
|
|
const colors = EXPERT_COLORS[state.def.name];
|
|
const bg = colors?.bg ?? "";
|
|
const br = colors?.br ?? "";
|
|
const bgr = bg ? BG_RESET : "";
|
|
const fgr = br ? FG_RESET : "";
|
|
|
|
// br colors the box-drawing characters; bg fills behind them so the
|
|
// full card — top line, side bars, bottom line — is one solid block.
|
|
const bord = (s: string) => bg + br + s + bgr + fgr;
|
|
|
|
const top = "┌" + "─".repeat(w) + "┐";
|
|
const bot = "└" + "─".repeat(w) + "┘";
|
|
|
|
// bg fills the inner content area; re-applied before padding to ensure
|
|
// the full row is colored even if theme.fg uses a full ANSI reset inside.
|
|
const border = (content: string, visLen: number) => {
|
|
const pad = " ".repeat(Math.max(0, w - visLen));
|
|
return bord("│") + bg + content + bg + pad + bgr + bord("│");
|
|
};
|
|
|
|
return [
|
|
bord(top),
|
|
border(" " + nameStr, 1 + nameVisible),
|
|
border(" " + statusLine, 1 + statusVisible),
|
|
border(" " + workLine, 1 + workVisible),
|
|
border(" " + lastLineRendered, 1 + lastVisible),
|
|
bord(bot),
|
|
];
|
|
}
|
|
|
|
function updateWidget() {
|
|
if (!widgetCtx) return;
|
|
|
|
widgetCtx.ui.setWidget("pi-pi-grid", (_tui: any, theme: any) => {
|
|
|
|
return {
|
|
render(width: number): string[] {
|
|
if (experts.size === 0) {
|
|
return ["", theme.fg("dim", " No experts found. Add agent .md files to .pi/agents/pi-pi/")];
|
|
}
|
|
|
|
const cols = Math.min(gridCols, experts.size);
|
|
const gap = 1;
|
|
// avoid Text component's ANSI-width miscounting by returning raw lines
|
|
const colWidth = Math.floor((width - gap * (cols - 1)) / cols) - 1;
|
|
const allExperts = Array.from(experts.values());
|
|
|
|
const lines: string[] = [""]; // top margin
|
|
|
|
for (let i = 0; i < allExperts.length; i += cols) {
|
|
const rowExperts = allExperts.slice(i, i + cols);
|
|
const cards = rowExperts.map(e => renderCard(e, colWidth, theme));
|
|
|
|
while (cards.length < cols) {
|
|
cards.push(Array(6).fill(" ".repeat(colWidth)));
|
|
}
|
|
|
|
const cardHeight = cards[0].length;
|
|
for (let line = 0; line < cardHeight; line++) {
|
|
lines.push(cards.map(card => card[line] || "").join(" ".repeat(gap)));
|
|
}
|
|
}
|
|
|
|
return lines;
|
|
},
|
|
invalidate() {},
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── Query Expert ─────────────────────────────
|
|
|
|
function queryExpert(
|
|
expertName: string,
|
|
question: string,
|
|
ctx: any,
|
|
): Promise<{ output: string; exitCode: number; elapsed: number }> {
|
|
const key = expertName.toLowerCase();
|
|
const state = experts.get(key);
|
|
if (!state) {
|
|
return Promise.resolve({
|
|
output: `Expert "${expertName}" not found. Available: ${Array.from(experts.values()).map(s => s.def.name).join(", ")}`,
|
|
exitCode: 1,
|
|
elapsed: 0,
|
|
});
|
|
}
|
|
|
|
if (state.status === "researching") {
|
|
return Promise.resolve({
|
|
output: `Expert "${displayName(state.def.name)}" is already researching. Wait for it to finish.`,
|
|
exitCode: 1,
|
|
elapsed: 0,
|
|
});
|
|
}
|
|
|
|
state.status = "researching";
|
|
state.question = question;
|
|
state.elapsed = 0;
|
|
state.lastLine = "";
|
|
state.queryCount++;
|
|
updateWidget();
|
|
|
|
const startTime = Date.now();
|
|
state.timer = setInterval(() => {
|
|
state.elapsed = Date.now() - startTime;
|
|
updateWidget();
|
|
}, 1000);
|
|
|
|
const model = ctx.model
|
|
? `${ctx.model.provider}/${ctx.model.id}`
|
|
: "openrouter/google/gemini-3-flash-preview";
|
|
|
|
const args = [
|
|
"--mode", "json",
|
|
"-p",
|
|
"--no-session",
|
|
"--no-extensions",
|
|
"--model", model,
|
|
"--tools", state.def.tools,
|
|
"--thinking", "off",
|
|
"--append-system-prompt", state.def.systemPrompt,
|
|
question,
|
|
];
|
|
|
|
const textChunks: string[] = [];
|
|
|
|
return new Promise((resolve) => {
|
|
const proc = spawn("pi", args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: { ...process.env },
|
|
});
|
|
|
|
let buffer = "";
|
|
|
|
proc.stdout!.setEncoding("utf-8");
|
|
proc.stdout!.on("data", (chunk: string) => {
|
|
buffer += chunk;
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const event = JSON.parse(line);
|
|
if (event.type === "message_update") {
|
|
const delta = event.assistantMessageEvent;
|
|
if (delta?.type === "text_delta") {
|
|
textChunks.push(delta.delta || "");
|
|
const full = textChunks.join("");
|
|
const last = full.split("\n").filter((l: string) => l.trim()).pop() || "";
|
|
state.lastLine = last;
|
|
updateWidget();
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
});
|
|
|
|
proc.stderr!.setEncoding("utf-8");
|
|
proc.stderr!.on("data", () => {});
|
|
|
|
proc.on("close", (code) => {
|
|
if (buffer.trim()) {
|
|
try {
|
|
const event = JSON.parse(buffer);
|
|
if (event.type === "message_update") {
|
|
const delta = event.assistantMessageEvent;
|
|
if (delta?.type === "text_delta") textChunks.push(delta.delta || "");
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
clearInterval(state.timer);
|
|
state.elapsed = Date.now() - startTime;
|
|
state.status = code === 0 ? "done" : "error";
|
|
|
|
const full = textChunks.join("");
|
|
state.lastLine = full.split("\n").filter((l: string) => l.trim()).pop() || "";
|
|
updateWidget();
|
|
|
|
ctx.ui.notify(
|
|
`${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`,
|
|
state.status === "done" ? "success" : "error"
|
|
);
|
|
|
|
resolve({
|
|
output: full,
|
|
exitCode: code ?? 1,
|
|
elapsed: state.elapsed,
|
|
});
|
|
});
|
|
|
|
proc.on("error", (err) => {
|
|
clearInterval(state.timer);
|
|
state.status = "error";
|
|
state.lastLine = `Error: ${err.message}`;
|
|
updateWidget();
|
|
resolve({
|
|
output: `Error spawning expert: ${err.message}`,
|
|
exitCode: 1,
|
|
elapsed: Date.now() - startTime,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── query_experts Tool (parallel) ───────────
|
|
|
|
pi.registerTool({
|
|
name: "query_experts",
|
|
label: "Query Experts",
|
|
description: `Query one or more Pi domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses.
|
|
|
|
Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together.
|
|
|
|
Available experts:
|
|
- ext-expert: Extensions — tools, events, commands, rendering, state management
|
|
- theme-expert: Themes — JSON format, 51 color tokens, vars, color values
|
|
- skill-expert: Skills — SKILL.md multi-file packages, scripts, references, frontmatter
|
|
- config-expert: Settings — settings.json, providers, models, packages, keybindings
|
|
- tui-expert: TUI — components, keyboard input, overlays, widgets, footers, editors
|
|
- prompt-expert: Prompt templates — single-file .md commands, arguments ($1, $@)
|
|
- agent-expert: Agent definitions — .md personas, tools, teams.yaml, orchestration
|
|
- keybinding-expert: Keyboard shortcuts — registerShortcut(), Key IDs, reserved keys, macOS terminal compatibility
|
|
|
|
Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`,
|
|
|
|
parameters: Type.Object({
|
|
queries: Type.Array(
|
|
Type.Object({
|
|
expert: Type.String({
|
|
description: "Expert name: ext-expert, theme-expert, skill-expert, config-expert, tui-expert, prompt-expert, or agent-expert",
|
|
}),
|
|
question: Type.String({
|
|
description: "Specific question about what you need to build. Include context about the target component.",
|
|
}),
|
|
}),
|
|
{ description: "Array of expert queries to run in parallel" },
|
|
),
|
|
}),
|
|
|
|
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
|
|
const { queries } = params as { queries: { expert: string; question: string }[] };
|
|
|
|
if (!queries || queries.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: "No queries provided." }],
|
|
details: { results: [], status: "error" },
|
|
};
|
|
}
|
|
|
|
const names = queries.map(q => displayName(q.expert)).join(", ");
|
|
if (onUpdate) {
|
|
onUpdate({
|
|
content: [{ type: "text", text: `Querying ${queries.length} experts in parallel: ${names}` }],
|
|
details: { queries, status: "researching", results: [] },
|
|
});
|
|
}
|
|
|
|
// Launch ALL experts concurrently — allSettled so one failure
|
|
// never discards results from the others
|
|
const settled = await Promise.allSettled(
|
|
queries.map(async ({ expert, question }) => {
|
|
const result = await queryExpert(expert, question, ctx);
|
|
const truncated = result.output.length > 12000
|
|
? result.output.slice(0, 12000) + "\n\n... [truncated — ask follow-up for more]"
|
|
: result.output;
|
|
const status = result.exitCode === 0 ? "done" : "error";
|
|
return {
|
|
expert,
|
|
question,
|
|
status,
|
|
elapsed: result.elapsed,
|
|
exitCode: result.exitCode,
|
|
output: truncated,
|
|
fullOutput: result.output,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const results = settled.map((s, i) =>
|
|
s.status === "fulfilled"
|
|
? s.value
|
|
: {
|
|
expert: queries[i].expert,
|
|
question: queries[i].question,
|
|
status: "error" as const,
|
|
elapsed: 0,
|
|
exitCode: 1,
|
|
output: `Error: ${(s.reason as any)?.message || s.reason}`,
|
|
fullOutput: "",
|
|
},
|
|
);
|
|
|
|
// Build combined response
|
|
const sections = results.map(r => {
|
|
const icon = r.status === "done" ? "✓" : "✗";
|
|
return `## [${icon}] ${displayName(r.expert)} (${Math.round(r.elapsed / 1000)}s)\n\n${r.output}`;
|
|
});
|
|
|
|
return {
|
|
content: [{ type: "text", text: sections.join("\n\n---\n\n") }],
|
|
details: {
|
|
results,
|
|
status: results.every(r => r.status === "done") ? "done" : "partial",
|
|
},
|
|
};
|
|
},
|
|
|
|
renderCall(args, theme) {
|
|
const queries = (args as any).queries || [];
|
|
const names = queries.map((q: any) => displayName(q.expert || "?")).join(", ");
|
|
return new Text(
|
|
theme.fg("toolTitle", theme.bold("query_experts ")) +
|
|
theme.fg("accent", `${queries.length} parallel`) +
|
|
theme.fg("dim", " — ") +
|
|
theme.fg("muted", names),
|
|
0, 0,
|
|
);
|
|
},
|
|
|
|
renderResult(result, options, theme) {
|
|
const details = result.details as any;
|
|
if (!details?.results) {
|
|
const text = result.content[0];
|
|
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
}
|
|
|
|
if (options.isPartial || details.status === "researching") {
|
|
const count = details.queries?.length || "?";
|
|
return new Text(
|
|
theme.fg("accent", `◉ ${count} experts`) +
|
|
theme.fg("dim", " researching in parallel..."),
|
|
0, 0,
|
|
);
|
|
}
|
|
|
|
const lines = (details.results as any[]).map((r: any) => {
|
|
const icon = r.status === "done" ? "✓" : "✗";
|
|
const color = r.status === "done" ? "success" : "error";
|
|
const elapsed = typeof r.elapsed === "number" ? Math.round(r.elapsed / 1000) : 0;
|
|
return theme.fg(color, `${icon} ${displayName(r.expert)}`) +
|
|
theme.fg("dim", ` ${elapsed}s`);
|
|
});
|
|
|
|
const header = lines.join(theme.fg("dim", " · "));
|
|
|
|
if (options.expanded && details.results) {
|
|
const expanded = (details.results as any[]).map((r: any) => {
|
|
const output = r.fullOutput
|
|
? (r.fullOutput.length > 4000 ? r.fullOutput.slice(0, 4000) + "\n... [truncated]" : r.fullOutput)
|
|
: r.output || "";
|
|
return theme.fg("accent", `── ${displayName(r.expert)} ──`) + "\n" + theme.fg("muted", output);
|
|
});
|
|
return new Text(header + "\n\n" + expanded.join("\n\n"), 0, 0);
|
|
}
|
|
|
|
return new Text(header, 0, 0);
|
|
},
|
|
});
|
|
|
|
// ── Commands ─────────────────────────────────
|
|
|
|
pi.registerCommand("experts", {
|
|
description: "List available Pi Pi experts and their status",
|
|
handler: async (_args, _ctx) => {
|
|
widgetCtx = _ctx;
|
|
const lines = Array.from(experts.values())
|
|
.map(s => `${displayName(s.def.name)} (${s.status}, queries: ${s.queryCount}): ${s.def.description}`)
|
|
.join("\n");
|
|
_ctx.ui.notify(lines || "No experts loaded", "info");
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("experts-grid", {
|
|
description: "Set expert grid columns: /experts-grid <1-5>",
|
|
handler: async (args, _ctx) => {
|
|
widgetCtx = _ctx;
|
|
const n = parseInt(args?.trim() || "", 10);
|
|
if (n >= 1 && n <= 5) {
|
|
gridCols = n;
|
|
_ctx.ui.notify(`Grid set to ${gridCols} columns`, "info");
|
|
updateWidget();
|
|
} else {
|
|
_ctx.ui.notify("Usage: /experts-grid <1-5>", "error");
|
|
}
|
|
},
|
|
});
|
|
|
|
// ── System Prompt ────────────────────────────
|
|
|
|
pi.on("before_agent_start", async (_event, _ctx) => {
|
|
const expertCatalog = Array.from(experts.values())
|
|
.map(s => `### ${displayName(s.def.name)}\n**Query as:** \`${s.def.name}\`\n${s.def.description}`)
|
|
.join("\n\n");
|
|
|
|
const expertNames = Array.from(experts.values()).map(s => displayName(s.def.name)).join(", ");
|
|
|
|
const orchestratorPath = join(_ctx.cwd, ".pi", "agents", "pi-pi", "pi-orchestrator.md");
|
|
let systemPrompt = "";
|
|
try {
|
|
const raw = readFileSync(orchestratorPath, "utf-8");
|
|
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
const template = match ? match[2].trim() : raw;
|
|
|
|
systemPrompt = template
|
|
.replace("{{EXPERT_COUNT}}", experts.size.toString())
|
|
.replace("{{EXPERT_NAMES}}", expertNames)
|
|
.replace("{{EXPERT_CATALOG}}", expertCatalog);
|
|
} catch (err) {
|
|
systemPrompt = "Error: Could not load pi-orchestrator.md. Make sure it exists in .pi/agents/pi-pi/.";
|
|
}
|
|
|
|
return { systemPrompt };
|
|
});
|
|
|
|
// ── Session Start ────────────────────────────
|
|
|
|
pi.on("session_start", async (_event, _ctx) => {
|
|
applyExtensionDefaults(import.meta.url, _ctx);
|
|
if (widgetCtx) {
|
|
widgetCtx.ui.setWidget("pi-pi-grid", undefined);
|
|
}
|
|
widgetCtx = _ctx;
|
|
|
|
loadExperts(_ctx.cwd);
|
|
updateWidget();
|
|
|
|
const expertNames = Array.from(experts.values()).map(s => displayName(s.def.name)).join(", ");
|
|
_ctx.ui.setStatus("pi-pi", `Pi Pi (${experts.size} experts)`);
|
|
_ctx.ui.notify(
|
|
`Pi Pi loaded — ${experts.size} experts: ${expertNames}\n\n` +
|
|
`/experts List experts and status\n` +
|
|
`/experts-grid N Set grid columns (1-5)\n\n` +
|
|
`Ask me to build any Pi agent component!`,
|
|
"info",
|
|
);
|
|
|
|
// Custom footer
|
|
_ctx.ui.setFooter((_tui, theme, _footerData) => ({
|
|
dispose: () => {},
|
|
invalidate() {},
|
|
render(width: number): string[] {
|
|
const model = _ctx.model?.id || "no-model";
|
|
const usage = _ctx.getContextUsage();
|
|
const pct = usage ? usage.percent : 0;
|
|
const filled = Math.round(pct / 10);
|
|
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
|
|
|
|
const active = Array.from(experts.values()).filter(e => e.status === "researching").length;
|
|
const done = Array.from(experts.values()).filter(e => e.status === "done").length;
|
|
|
|
const left = theme.fg("dim", ` ${model}`) +
|
|
theme.fg("muted", " · ") +
|
|
theme.fg("accent", "Pi Pi");
|
|
const mid = active > 0
|
|
? theme.fg("accent", ` ◉ ${active} researching`)
|
|
: done > 0
|
|
? theme.fg("success", ` ✓ ${done} done`)
|
|
: "";
|
|
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
|
|
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(mid) - visibleWidth(right)));
|
|
|
|
return [truncateToWidth(left + mid + pad + right, width)];
|
|
},
|
|
}));
|
|
});
|
|
}
|