This commit is contained in:
IndyDevDan
2026-02-22 20:19:33 -06:00
commit 32dfe122cb
68 changed files with 8173 additions and 0 deletions

797
extensions/agent-chain.ts Normal file
View File

@@ -0,0 +1,797 @@
/**
* Agent Chain — Sequential pipeline orchestrator
*
* Runs opinionated, repeatable agent workflows. Chains are defined in
* .pi/agents/agent-chain.yaml — each chain is a sequence of agent steps
* with prompt templates. The user's original prompt flows into step 1,
* the output becomes $INPUT for step 2's prompt template, and so on.
* $ORIGINAL is always the user's original prompt.
*
* The primary Pi agent has NO codebase tools — it can ONLY kick off the
* pipeline via the `run_chain` tool. On boot you select a chain; the
* agent decides when to run it based on the user's prompt.
*
* Agents maintain session context within a Pi session — re-running the
* chain lets each agent resume where it left off.
*
* Commands:
* /chain — switch active chain
* /chain-list — list all available chains
*
* Usage: pi -e extensions/agent-chain.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 { readFileSync, existsSync, readdirSync, mkdirSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Types ────────────────────────────────────────
interface ChainStep {
agent: string;
prompt: string;
}
interface ChainDef {
name: string;
description: string;
steps: ChainStep[];
}
interface AgentDef {
name: string;
description: string;
tools: string;
systemPrompt: string;
}
interface StepState {
agent: string;
status: "pending" | "running" | "done" | "error";
elapsed: number;
lastWork: string;
}
// ── Display Name Helper ──────────────────────────
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
// ── Chain YAML Parser ────────────────────────────
function parseChainYaml(raw: string): ChainDef[] {
const chains: ChainDef[] = [];
let current: ChainDef | null = null;
let currentStep: ChainStep | null = null;
for (const line of raw.split("\n")) {
// Chain name: top-level key
const chainMatch = line.match(/^(\S[^:]*):$/);
if (chainMatch) {
if (current && currentStep) {
current.steps.push(currentStep);
currentStep = null;
}
current = { name: chainMatch[1].trim(), description: "", steps: [] };
chains.push(current);
continue;
}
// Chain description
const descMatch = line.match(/^\s+description:\s+(.+)$/);
if (descMatch && current && !currentStep) {
let desc = descMatch[1].trim();
if ((desc.startsWith('"') && desc.endsWith('"')) ||
(desc.startsWith("'") && desc.endsWith("'"))) {
desc = desc.slice(1, -1);
}
current.description = desc;
continue;
}
// "steps:" label — skip
if (line.match(/^\s+steps:\s*$/) && current) {
continue;
}
// Step agent line
const agentMatch = line.match(/^\s+-\s+agent:\s+(.+)$/);
if (agentMatch && current) {
if (currentStep) {
current.steps.push(currentStep);
}
currentStep = { agent: agentMatch[1].trim(), prompt: "" };
continue;
}
// Step prompt line
const promptMatch = line.match(/^\s+prompt:\s+(.+)$/);
if (promptMatch && currentStep) {
let prompt = promptMatch[1].trim();
if ((prompt.startsWith('"') && prompt.endsWith('"')) ||
(prompt.startsWith("'") && prompt.endsWith("'"))) {
prompt = prompt.slice(1, -1);
}
prompt = prompt.replace(/\\n/g, "\n");
currentStep.prompt = prompt;
continue;
}
}
if (current && currentStep) {
current.steps.push(currentStep);
}
return chains;
}
// ── Frontmatter Parser ───────────────────────────
function parseAgentFile(filePath: string): AgentDef | 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(),
};
} catch {
return null;
}
}
function scanAgentDirs(cwd: string): Map<string, AgentDef> {
const dirs = [
join(cwd, "agents"),
join(cwd, ".claude", "agents"),
join(cwd, ".pi", "agents"),
];
const agents = new Map<string, AgentDef>();
for (const dir of dirs) {
if (!existsSync(dir)) continue;
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const fullPath = resolve(dir, file);
const def = parseAgentFile(fullPath);
if (def && !agents.has(def.name.toLowerCase())) {
agents.set(def.name.toLowerCase(), def);
}
}
} catch {}
}
return agents;
}
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
let allAgents: Map<string, AgentDef> = new Map();
let chains: ChainDef[] = [];
let activeChain: ChainDef | null = null;
let widgetCtx: any;
let sessionDir = "";
const agentSessions: Map<string, string | null> = new Map();
// Per-step state for the active chain
let stepStates: StepState[] = [];
let pendingReset = false;
function loadChains(cwd: string) {
sessionDir = join(cwd, ".pi", "agent-sessions");
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
allAgents = scanAgentDirs(cwd);
agentSessions.clear();
for (const [key] of allAgents) {
const sessionFile = join(sessionDir, `chain-${key}.json`);
agentSessions.set(key, existsSync(sessionFile) ? sessionFile : null);
}
const chainPath = join(cwd, ".pi", "agents", "agent-chain.yaml");
if (existsSync(chainPath)) {
try {
chains = parseChainYaml(readFileSync(chainPath, "utf-8"));
} catch {
chains = [];
}
} else {
chains = [];
}
}
function activateChain(chain: ChainDef) {
activeChain = chain;
stepStates = chain.steps.map(s => ({
agent: s.agent,
status: "pending" as const,
elapsed: 0,
lastWork: "",
}));
// Skip widget re-registration if reset is pending — let before_agent_start handle it
if (!pendingReset) {
updateWidget();
}
}
// ── Card Rendering ──────────────────────────
function renderCard(state: StepState, 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 === "pending" ? "dim"
: state.status === "running" ? "accent"
: state.status === "done" ? "success" : "error";
const statusIcon = state.status === "pending" ? "○"
: state.status === "running" ? "●"
: state.status === "done" ? "✓" : "✗";
const name = displayName(state.agent);
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 !== "pending" ? ` ${Math.round(state.elapsed / 1000)}s` : "";
const statusLine = theme.fg(statusColor, statusStr + timeStr);
const statusVisible = statusStr.length + timeStr.length;
const workRaw = state.lastWork || "";
const workText = workRaw ? truncate(workRaw, Math.min(50, w - 1)) : "";
const workLine = workText ? theme.fg("muted", workText) : theme.fg("dim", "—");
const workVisible = workText ? workText.length : 1;
const top = "┌" + "─".repeat(w) + "┐";
const bot = "└" + "─".repeat(w) + "┘";
const border = (content: string, visLen: number) =>
theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│");
return [
theme.fg("dim", top),
border(" " + nameStr, 1 + nameVisible),
border(" " + statusLine, 1 + statusVisible),
border(" " + workLine, 1 + workVisible),
theme.fg("dim", bot),
];
}
function updateWidget() {
if (!widgetCtx) return;
widgetCtx.ui.setWidget("agent-chain", (_tui: any, theme: any) => {
const text = new Text("", 0, 1);
return {
render(width: number): string[] {
if (!activeChain || stepStates.length === 0) {
text.setText(theme.fg("dim", "No chain active. Use /chain to select one."));
return text.render(width);
}
const arrowWidth = 5; // " ──▶ "
const cols = stepStates.length;
const totalArrowWidth = arrowWidth * (cols - 1);
const colWidth = Math.max(12, Math.floor((width - totalArrowWidth) / cols));
const arrowRow = 2; // middle of 5-line card (0-indexed)
const cards = stepStates.map(s => renderCard(s, colWidth, theme));
const cardHeight = cards[0].length;
const outputLines: string[] = [];
for (let line = 0; line < cardHeight; line++) {
let row = cards[0][line];
for (let c = 1; c < cols; c++) {
if (line === arrowRow) {
row += theme.fg("dim", " ──▶ ");
} else {
row += " ".repeat(arrowWidth);
}
row += cards[c][line];
}
outputLines.push(row);
}
text.setText(outputLines.join("\n"));
return text.render(width);
},
invalidate() {
text.invalidate();
},
};
});
}
// ── Run Agent (subprocess) ──────────────────
function runAgent(
agentDef: AgentDef,
task: string,
stepIndex: number,
ctx: any,
): Promise<{ output: string; exitCode: number; elapsed: number }> {
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview";
const agentKey = agentDef.name.toLowerCase().replace(/\s+/g, "-");
const agentSessionFile = join(sessionDir, `chain-${agentKey}.json`);
const hasSession = agentSessions.get(agentKey);
const args = [
"--mode", "json",
"-p",
"--no-extensions",
"--model", model,
"--tools", agentDef.tools,
"--thinking", "off",
"--append-system-prompt", agentDef.systemPrompt,
"--session", agentSessionFile,
];
if (hasSession) {
args.push("-c");
}
args.push(task);
const textChunks: string[] = [];
const startTime = Date.now();
const state = stepStates[stepIndex];
return new Promise((resolve) => {
const proc = spawn("pi", args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
const timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
updateWidget();
}, 1000);
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.lastWork = 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(timer);
const elapsed = Date.now() - startTime;
state.elapsed = elapsed;
const output = textChunks.join("");
state.lastWork = output.split("\n").filter((l: string) => l.trim()).pop() || "";
if (code === 0) {
agentSessions.set(agentKey, agentSessionFile);
}
resolve({ output, exitCode: code ?? 1, elapsed });
});
proc.on("error", (err) => {
clearInterval(timer);
resolve({
output: `Error spawning agent: ${err.message}`,
exitCode: 1,
elapsed: Date.now() - startTime,
});
});
});
}
// ── Run Chain (sequential pipeline) ─────────
async function runChain(
task: string,
ctx: any,
): Promise<{ output: string; success: boolean; elapsed: number }> {
if (!activeChain) {
return { output: "No chain active", success: false, elapsed: 0 };
}
const chainStart = Date.now();
// Reset all steps to pending
stepStates = activeChain.steps.map(s => ({
agent: s.agent,
status: "pending" as const,
elapsed: 0,
lastWork: "",
}));
updateWidget();
let input = task;
const originalPrompt = task;
for (let i = 0; i < activeChain.steps.length; i++) {
const step = activeChain.steps[i];
stepStates[i].status = "running";
updateWidget();
const resolvedPrompt = step.prompt
.replace(/\$INPUT/g, input)
.replace(/\$ORIGINAL/g, originalPrompt);
const agentDef = allAgents.get(step.agent.toLowerCase());
if (!agentDef) {
stepStates[i].status = "error";
stepStates[i].lastWork = `Agent "${step.agent}" not found`;
updateWidget();
return {
output: `Error at step ${i + 1}: Agent "${step.agent}" not found. Available: ${Array.from(allAgents.keys()).join(", ")}`,
success: false,
elapsed: Date.now() - chainStart,
};
}
const result = await runAgent(agentDef, resolvedPrompt, i, ctx);
if (result.exitCode !== 0) {
stepStates[i].status = "error";
updateWidget();
return {
output: `Error at step ${i + 1} (${step.agent}): ${result.output}`,
success: false,
elapsed: Date.now() - chainStart,
};
}
stepStates[i].status = "done";
updateWidget();
input = result.output;
}
return { output: input, success: true, elapsed: Date.now() - chainStart };
}
// ── run_chain Tool ──────────────────────────
pi.registerTool({
name: "run_chain",
label: "Run Chain",
description: "Execute the active agent chain pipeline. Each step runs sequentially — output from one step feeds into the next. Agents maintain session context across runs.",
parameters: Type.Object({
task: Type.String({ description: "The task/prompt for the chain to process" }),
}),
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
const { task } = params as { task: string };
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Starting chain: ${activeChain?.name}...` }],
details: { chain: activeChain?.name, task, status: "running" },
});
}
const result = await runChain(task, ctx);
const truncated = result.output.length > 8000
? result.output.slice(0, 8000) + "\n\n... [truncated]"
: result.output;
const status = result.success ? "done" : "error";
const summary = `[chain:${activeChain?.name}] ${status} in ${Math.round(result.elapsed / 1000)}s`;
return {
content: [{ type: "text", text: `${summary}\n\n${truncated}` }],
details: {
chain: activeChain?.name,
task,
status,
elapsed: result.elapsed,
fullOutput: result.output,
},
};
},
renderCall(args, theme) {
const task = (args as any).task || "";
const preview = task.length > 60 ? task.slice(0, 57) + "..." : task;
return new Text(
theme.fg("toolTitle", theme.bold("run_chain ")) +
theme.fg("accent", activeChain?.name || "?") +
theme.fg("dim", " — ") +
theme.fg("muted", preview),
0, 0,
);
},
renderResult(result, options, theme) {
const details = result.details as any;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (options.isPartial || details.status === "running") {
return new Text(
theme.fg("accent", `${details.chain || "chain"}`) +
theme.fg("dim", " running..."),
0, 0,
);
}
const icon = details.status === "done" ? "✓" : "✗";
const color = details.status === "done" ? "success" : "error";
const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0;
const header = theme.fg(color, `${icon} ${details.chain}`) +
theme.fg("dim", ` ${elapsed}s`);
if (options.expanded && details.fullOutput) {
const output = details.fullOutput.length > 4000
? details.fullOutput.slice(0, 4000) + "\n... [truncated]"
: details.fullOutput;
return new Text(header + "\n" + theme.fg("muted", output), 0, 0);
}
return new Text(header, 0, 0);
},
});
// ── Commands ─────────────────────────────────
pi.registerCommand("chain", {
description: "Switch active chain",
handler: async (_args, ctx) => {
widgetCtx = ctx;
if (chains.length === 0) {
ctx.ui.notify("No chains defined in .pi/agents/agent-chain.yaml", "warning");
return;
}
const options = chains.map(c => {
const steps = c.steps.map(s => displayName(s.agent)).join(" → ");
const desc = c.description ? `${c.description}` : "";
return `${c.name}${desc} (${steps})`;
});
const choice = await ctx.ui.select("Select Chain", options);
if (choice === undefined) return;
const idx = options.indexOf(choice);
activateChain(chains[idx]);
const flow = chains[idx].steps.map(s => displayName(s.agent)).join(" → ");
ctx.ui.setStatus("agent-chain", `Chain: ${chains[idx].name} (${chains[idx].steps.length} steps)`);
ctx.ui.notify(
`Chain: ${chains[idx].name}\n${chains[idx].description}\n${flow}`,
"info",
);
},
});
pi.registerCommand("chain-list", {
description: "List all available chains",
handler: async (_args, ctx) => {
widgetCtx = ctx;
if (chains.length === 0) {
ctx.ui.notify("No chains defined in .pi/agents/agent-chain.yaml", "warning");
return;
}
const list = chains.map(c => {
const desc = c.description ? ` ${c.description}` : "";
const steps = c.steps.map((s, i) =>
` ${i + 1}. ${displayName(s.agent)}`
).join("\n");
return `${c.name}:${desc ? "\n" + desc : ""}\n${steps}`;
}).join("\n\n");
ctx.ui.notify(list, "info");
},
});
// ── System Prompt Override ───────────────────
pi.on("before_agent_start", async (_event, _ctx) => {
// Force widget reset on first turn after /new
if (pendingReset && activeChain) {
pendingReset = false;
widgetCtx = _ctx;
stepStates = activeChain.steps.map(s => ({
agent: s.agent,
status: "pending" as const,
elapsed: 0,
lastWork: "",
}));
updateWidget();
}
if (!activeChain) return {};
const flow = activeChain.steps.map(s => displayName(s.agent)).join(" → ");
const desc = activeChain.description ? `\n${activeChain.description}` : "";
// Build pipeline steps summary
const steps = activeChain.steps.map((s, i) => {
const agentDef = allAgents.get(s.agent.toLowerCase());
const agentDesc = agentDef?.description || "";
return `${i + 1}. **${displayName(s.agent)}** — ${agentDesc}`;
}).join("\n");
// Build full agent catalog (like agent-team.ts)
const seen = new Set<string>();
const agentCatalog = activeChain.steps
.filter(s => {
const key = s.agent.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(s => {
const agentDef = allAgents.get(s.agent.toLowerCase());
if (!agentDef) return `### ${displayName(s.agent)}\nAgent not found.`;
return `### ${displayName(agentDef.name)}\n${agentDef.description}\n**Tools:** ${agentDef.tools}\n**Role:** ${agentDef.systemPrompt}`;
})
.join("\n\n");
return {
systemPrompt: `You are an agent with a sequential pipeline called "${activeChain.name}" at your disposal.${desc}
You have full access to your own tools AND the run_chain tool to delegate to your team.
## Active Chain: ${activeChain.name}
Flow: ${flow}
${steps}
## Agent Details
${agentCatalog}
## When to Use run_chain
- Significant work: new features, refactors, multi-file changes, anything non-trivial
- Tasks that benefit from the full pipeline: planning, building, reviewing
- When you want structured, multi-agent collaboration on a problem
## When to Work Directly
- Simple one-off commands: reading a file, checking status, listing contents
- Quick lookups, small edits, answering questions about the codebase
- Anything you can handle in a single step without needing the pipeline
## How run_chain Works
- Pass a clear task description to run_chain
- Each step's output feeds into the next step as $INPUT
- Agents maintain session context — they remember previous work within this session
- You can run the chain multiple times with different tasks if needed
- After the chain completes, review the result and summarize for the user
## Guidelines
- Use your judgment — if it's quick, just do it; if it's real work, run the chain
- Keep chain tasks focused and clearly described
- You can mix direct work and chain runs in the same conversation`,
};
});
// ── Session Start ───────────────────────────
pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx);
// Clear widget with both old and new ctx — one of them will be valid
if (widgetCtx) {
widgetCtx.ui.setWidget("agent-chain", undefined);
}
_ctx.ui.setWidget("agent-chain", undefined);
widgetCtx = _ctx;
// Reset execution state — widget re-registration deferred to before_agent_start
stepStates = [];
activeChain = null;
pendingReset = true;
// Wipe chain session files — reset agent context on /new and launch
const sessDir = join(_ctx.cwd, ".pi", "agent-sessions");
if (existsSync(sessDir)) {
for (const f of readdirSync(sessDir)) {
if (f.startsWith("chain-") && f.endsWith(".json")) {
try { unlinkSync(join(sessDir, f)); } catch {}
}
}
}
// Reload chains + clear agentSessions map (all agents start fresh)
loadChains(_ctx.cwd);
if (chains.length === 0) {
_ctx.ui.notify("No chains found in .pi/agents/agent-chain.yaml", "warning");
return;
}
// Default to first chain — use /chain to switch
activateChain(chains[0]);
// run_chain is registered as a tool — available alongside all default tools
const flow = activeChain!.steps.map(s => displayName(s.agent)).join(" → ");
_ctx.ui.setStatus("agent-chain", `Chain: ${activeChain!.name} (${activeChain!.steps.length} steps)`);
_ctx.ui.notify(
`Chain: ${activeChain!.name}\n${activeChain!.description}\n${flow}\n\n` +
`/chain Switch chain\n` +
`/chain-list List all chains`,
"info",
);
// Footer: model | chain name | context bar
_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 chainLabel = activeChain
? theme.fg("accent", activeChain.name)
: theme.fg("dim", "no chain");
const left = theme.fg("dim", ` ${model}`) +
theme.fg("muted", " · ") +
chainLabel;
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
}));
});
}

734
extensions/agent-team.ts Normal file
View File

@@ -0,0 +1,734 @@
/**
* Agent Team — Dispatcher-only orchestrator with grid dashboard
*
* The primary Pi agent has NO codebase tools. It can ONLY delegate work
* to specialist agents via the `dispatch_agent` tool. Each specialist
* maintains its own Pi session for cross-invocation memory.
*
* Loads agent definitions from agents/*.md, .claude/agents/*.md, .pi/agents/*.md.
* Teams are defined in .pi/agents/teams.yaml — on boot a select dialog lets
* you pick which team to work with. Only team members are available for dispatch.
*
* Commands:
* /agents-team — switch active team
* /agents-list — list loaded agents
* /agents-grid N — set column count (default 2)
*
* Usage: pi -e extensions/agent-team.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { Text, type AutocompleteItem, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { spawn } from "child_process";
import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Types ────────────────────────────────────────
interface AgentDef {
name: string;
description: string;
tools: string;
systemPrompt: string;
file: string;
}
interface AgentState {
def: AgentDef;
status: "idle" | "running" | "done" | "error";
task: string;
toolCount: number;
elapsed: number;
lastWork: string;
contextPct: number;
sessionFile: string | null;
runCount: number;
timer?: ReturnType<typeof setInterval>;
}
// ── Display Name Helper ──────────────────────────
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
// ── Teams YAML Parser ────────────────────────────
function parseTeamsYaml(raw: string): Record<string, string[]> {
const teams: Record<string, string[]> = {};
let current: string | null = null;
for (const line of raw.split("\n")) {
const teamMatch = line.match(/^(\S[^:]*):$/);
if (teamMatch) {
current = teamMatch[1].trim();
teams[current] = [];
continue;
}
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch && current) {
teams[current].push(itemMatch[1].trim());
}
}
return teams;
}
// ── Frontmatter Parser ───────────────────────────
function parseAgentFile(filePath: string): AgentDef | 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;
}
}
function scanAgentDirs(cwd: string): AgentDef[] {
const dirs = [
join(cwd, "agents"),
join(cwd, ".claude", "agents"),
join(cwd, ".pi", "agents"),
];
const agents: AgentDef[] = [];
const seen = new Set<string>();
for (const dir of dirs) {
if (!existsSync(dir)) continue;
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const fullPath = resolve(dir, file);
const def = parseAgentFile(fullPath);
if (def && !seen.has(def.name.toLowerCase())) {
seen.add(def.name.toLowerCase());
agents.push(def);
}
}
} catch {}
}
return agents;
}
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
const agentStates: Map<string, AgentState> = new Map();
let allAgentDefs: AgentDef[] = [];
let teams: Record<string, string[]> = {};
let activeTeamName = "";
let gridCols = 2;
let widgetCtx: any;
let sessionDir = "";
let contextWindow = 0;
function loadAgents(cwd: string) {
// Create session storage dir
sessionDir = join(cwd, ".pi", "agent-sessions");
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
// Load all agent definitions
allAgentDefs = scanAgentDirs(cwd);
// Load teams from .pi/agents/teams.yaml
const teamsPath = join(cwd, ".pi", "agents", "teams.yaml");
if (existsSync(teamsPath)) {
try {
teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8"));
} catch {
teams = {};
}
} else {
teams = {};
}
// If no teams defined, create a default "all" team
if (Object.keys(teams).length === 0) {
teams = { all: allAgentDefs.map(d => d.name) };
}
}
function activateTeam(teamName: string) {
activeTeamName = teamName;
const members = teams[teamName] || [];
const defsByName = new Map(allAgentDefs.map(d => [d.name.toLowerCase(), d]));
agentStates.clear();
for (const member of members) {
const def = defsByName.get(member.toLowerCase());
if (!def) continue;
const key = def.name.toLowerCase().replace(/\s+/g, "-");
const sessionFile = join(sessionDir, `${key}.json`);
agentStates.set(def.name.toLowerCase(), {
def,
status: "idle",
task: "",
toolCount: 0,
elapsed: 0,
lastWork: "",
contextPct: 0,
sessionFile: existsSync(sessionFile) ? sessionFile : null,
runCount: 0,
});
}
// Auto-size grid columns based on team size
const size = agentStates.size;
gridCols = size <= 3 ? size : size === 4 ? 2 : 3;
}
// ── Grid Rendering ───────────────────────────
function renderCard(state: AgentState, 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 === "running" ? "accent"
: state.status === "done" ? "success" : "error";
const statusIcon = state.status === "idle" ? "○"
: state.status === "running" ? "●"
: 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 statusLine = theme.fg(statusColor, statusStr + timeStr);
const statusVisible = statusStr.length + timeStr.length;
// Context bar: 5 blocks + percent
const filled = Math.ceil(state.contextPct / 20);
const bar = "#".repeat(filled) + "-".repeat(5 - filled);
const ctxStr = `[${bar}] ${Math.ceil(state.contextPct)}%`;
const ctxLine = theme.fg("dim", ctxStr);
const ctxVisible = ctxStr.length;
const workRaw = state.task
? (state.lastWork || state.task)
: state.def.description;
const workText = truncate(workRaw, Math.min(50, w - 1));
const workLine = theme.fg("muted", workText);
const workVisible = workText.length;
const top = "┌" + "─".repeat(w) + "┐";
const bot = "└" + "─".repeat(w) + "┘";
const border = (content: string, visLen: number) =>
theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│");
return [
theme.fg("dim", top),
border(" " + nameStr, 1 + nameVisible),
border(" " + statusLine, 1 + statusVisible),
border(" " + ctxLine, 1 + ctxVisible),
border(" " + workLine, 1 + workVisible),
theme.fg("dim", bot),
];
}
function updateWidget() {
if (!widgetCtx) return;
widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => {
const text = new Text("", 0, 1);
return {
render(width: number): string[] {
if (agentStates.size === 0) {
text.setText(theme.fg("dim", "No agents found. Add .md files to agents/"));
return text.render(width);
}
const cols = Math.min(gridCols, agentStates.size);
const gap = 1;
const colWidth = Math.floor((width - gap * (cols - 1)) / cols);
const agents = Array.from(agentStates.values());
const rows: string[][] = [];
for (let i = 0; i < agents.length; i += cols) {
const rowAgents = agents.slice(i, i + cols);
const cards = rowAgents.map(a => renderCard(a, 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++) {
rows.push(cards.map(card => card[line] || ""));
}
}
const output = rows.map(cols => cols.join(" ".repeat(gap)));
text.setText(output.join("\n"));
return text.render(width);
},
invalidate() {
text.invalidate();
},
};
});
}
// ── Dispatch Agent (returns Promise) ─────────
function dispatchAgent(
agentName: string,
task: string,
ctx: any,
): Promise<{ output: string; exitCode: number; elapsed: number }> {
const key = agentName.toLowerCase();
const state = agentStates.get(key);
if (!state) {
return Promise.resolve({
output: `Agent "${agentName}" not found. Available: ${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`,
exitCode: 1,
elapsed: 0,
});
}
if (state.status === "running") {
return Promise.resolve({
output: `Agent "${displayName(state.def.name)}" is already running. Wait for it to finish.`,
exitCode: 1,
elapsed: 0,
});
}
state.status = "running";
state.task = task;
state.toolCount = 0;
state.elapsed = 0;
state.lastWork = "";
state.runCount++;
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";
// Session file for this agent
const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-");
const agentSessionFile = join(sessionDir, `${agentKey}.json`);
// Build args — first run creates session, subsequent runs resume
const args = [
"--mode", "json",
"-p",
"--no-extensions",
"--model", model,
"--tools", state.def.tools,
"--thinking", "off",
"--append-system-prompt", state.def.systemPrompt,
"--session", agentSessionFile,
];
// Continue existing session if we have one
if (state.sessionFile) {
args.push("-c");
}
args.push(task);
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.lastWork = last;
updateWidget();
}
} else if (event.type === "tool_execution_start") {
state.toolCount++;
updateWidget();
} else if (event.type === "message_end") {
const msg = event.message;
if (msg?.usage && contextWindow > 0) {
state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100;
updateWidget();
}
} else if (event.type === "agent_end") {
const msgs = event.messages || [];
const last = [...msgs].reverse().find((m: any) => m.role === "assistant");
if (last?.usage && contextWindow > 0) {
state.contextPct = ((last.usage.input || 0) / contextWindow) * 100;
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";
// Mark session file as available for resume
if (code === 0) {
state.sessionFile = agentSessionFile;
}
const full = textChunks.join("");
state.lastWork = 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.lastWork = `Error: ${err.message}`;
updateWidget();
resolve({
output: `Error spawning agent: ${err.message}`,
exitCode: 1,
elapsed: Date.now() - startTime,
});
});
});
}
// ── dispatch_agent Tool (registered at top level) ──
pi.registerTool({
name: "dispatch_agent",
label: "Dispatch Agent",
description: "Dispatch a task to a specialist agent. The agent will execute the task and return the result. Use the system prompt to see available agent names.",
parameters: Type.Object({
agent: Type.String({ description: "Agent name (case-insensitive)" }),
task: Type.String({ description: "Task description for the agent to execute" }),
}),
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
const { agent, task } = params as { agent: string; task: string };
try {
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Dispatching to ${agent}...` }],
details: { agent, task, status: "dispatching" },
});
}
const result = await dispatchAgent(agent, task, ctx);
const truncated = result.output.length > 8000
? result.output.slice(0, 8000) + "\n\n... [truncated]"
: result.output;
const status = result.exitCode === 0 ? "done" : "error";
const summary = `[${agent}] ${status} in ${Math.round(result.elapsed / 1000)}s`;
return {
content: [{ type: "text", text: `${summary}\n\n${truncated}` }],
details: {
agent,
task,
status,
elapsed: result.elapsed,
exitCode: result.exitCode,
fullOutput: result.output,
},
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Error dispatching to ${agent}: ${err?.message || err}` }],
details: { agent, task, status: "error", elapsed: 0, exitCode: 1, fullOutput: "" },
};
}
},
renderCall(args, theme) {
const agentName = (args as any).agent || "?";
const task = (args as any).task || "";
const preview = task.length > 60 ? task.slice(0, 57) + "..." : task;
return new Text(
theme.fg("toolTitle", theme.bold("dispatch_agent ")) +
theme.fg("accent", agentName) +
theme.fg("dim", " — ") +
theme.fg("muted", preview),
0, 0,
);
},
renderResult(result, options, theme) {
const details = result.details as any;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
// Streaming/partial result while agent is still running
if (options.isPartial || details.status === "dispatching") {
return new Text(
theme.fg("accent", `${details.agent || "?"}`) +
theme.fg("dim", " working..."),
0, 0,
);
}
const icon = details.status === "done" ? "✓" : "✗";
const color = details.status === "done" ? "success" : "error";
const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0;
const header = theme.fg(color, `${icon} ${details.agent}`) +
theme.fg("dim", ` ${elapsed}s`);
if (options.expanded && details.fullOutput) {
const output = details.fullOutput.length > 4000
? details.fullOutput.slice(0, 4000) + "\n... [truncated]"
: details.fullOutput;
return new Text(header + "\n" + theme.fg("muted", output), 0, 0);
}
return new Text(header, 0, 0);
},
});
// ── Commands ─────────────────────────────────
pi.registerCommand("agents-team", {
description: "Select a team to work with",
handler: async (_args, ctx) => {
widgetCtx = ctx;
const teamNames = Object.keys(teams);
if (teamNames.length === 0) {
ctx.ui.notify("No teams defined in .pi/agents/teams.yaml", "warning");
return;
}
const options = teamNames.map(name => {
const members = teams[name].map(m => displayName(m));
return `${name}${members.join(", ")}`;
});
const choice = await ctx.ui.select("Select Team", options);
if (choice === undefined) return;
const idx = options.indexOf(choice);
const name = teamNames[idx];
activateTeam(name);
updateWidget();
ctx.ui.setStatus("agent-team", `Team: ${name} (${agentStates.size})`);
ctx.ui.notify(`Team: ${name}${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`, "info");
},
});
pi.registerCommand("agents-list", {
description: "List all loaded agents",
handler: async (_args, _ctx) => {
widgetCtx = _ctx;
const names = Array.from(agentStates.values())
.map(s => {
const session = s.sessionFile ? "resumed" : "new";
return `${displayName(s.def.name)} (${s.status}, ${session}, runs: ${s.runCount}): ${s.def.description}`;
})
.join("\n");
_ctx.ui.notify(names || "No agents loaded", "info");
},
});
pi.registerCommand("agents-grid", {
description: "Set grid columns: /agents-grid <1-6>",
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
const items = ["1", "2", "3", "4", "5", "6"].map(n => ({
value: n,
label: `${n} columns`,
}));
const filtered = items.filter(i => i.value.startsWith(prefix));
return filtered.length > 0 ? filtered : items;
},
handler: async (args, _ctx) => {
widgetCtx = _ctx;
const n = parseInt(args?.trim() || "", 10);
if (n >= 1 && n <= 6) {
gridCols = n;
_ctx.ui.notify(`Grid set to ${gridCols} columns`, "info");
updateWidget();
} else {
_ctx.ui.notify("Usage: /agents-grid <1-6>", "error");
}
},
});
// ── System Prompt Override ───────────────────
pi.on("before_agent_start", async (_event, _ctx) => {
// Build dynamic agent catalog from active team only
const agentCatalog = Array.from(agentStates.values())
.map(s => `### ${displayName(s.def.name)}\n**Dispatch as:** \`${s.def.name}\`\n${s.def.description}\n**Tools:** ${s.def.tools}`)
.join("\n\n");
const teamMembers = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ");
return {
systemPrompt: `You are a dispatcher agent. You coordinate specialist agents to accomplish tasks.
You do NOT have direct access to the codebase. You MUST delegate all work through
agents using the dispatch_agent tool.
## Active Team: ${activeTeamName}
Members: ${teamMembers}
You can ONLY dispatch to agents listed below. Do not attempt to dispatch to agents outside this team.
## How to Work
- Analyze the user's request and break it into clear sub-tasks
- Choose the right agent(s) for each sub-task
- Dispatch tasks using the dispatch_agent tool
- Review results and dispatch follow-up agents if needed
- If a task fails, try a different agent or adjust the task description
- Summarize the outcome for the user
## Rules
- NEVER try to read, write, or execute code directly — you have no such tools
- ALWAYS use dispatch_agent to get work done
- You can chain agents: use scout to explore, then builder to implement
- You can dispatch the same agent multiple times with different tasks
- Keep tasks focused — one clear objective per dispatch
## Agents
${agentCatalog}`,
};
});
// ── Session Start ────────────────────────────
pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx);
// Clear widgets from previous session
if (widgetCtx) {
widgetCtx.ui.setWidget("agent-team", undefined);
}
widgetCtx = _ctx;
contextWindow = _ctx.model?.contextWindow || 0;
// Wipe old agent session files so subagents start fresh
const sessDir = join(_ctx.cwd, ".pi", "agent-sessions");
if (existsSync(sessDir)) {
for (const f of readdirSync(sessDir)) {
if (f.endsWith(".json")) {
try { unlinkSync(join(sessDir, f)); } catch {}
}
}
}
loadAgents(_ctx.cwd);
// Default to first team — use /agents-team to switch
const teamNames = Object.keys(teams);
if (teamNames.length > 0) {
activateTeam(teamNames[0]);
}
// Lock down to dispatcher-only (tool already registered at top level)
pi.setActiveTools(["dispatch_agent"]);
_ctx.ui.setStatus("agent-team", `Team: ${activeTeamName} (${agentStates.size})`);
const members = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ");
_ctx.ui.notify(
`Team: ${activeTeamName} (${members})\n` +
`Team sets loaded from: .pi/agents/teams.yaml\n\n` +
`/agents-team Select a team\n` +
`/agents-list List active agents and status\n` +
`/agents-grid <1-6> Set grid column count`,
"info",
);
updateWidget();
// Footer: model | team | context bar
_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 left = theme.fg("dim", ` ${model}`) +
theme.fg("muted", " · ") +
theme.fg("accent", activeTeamName);
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
}));
});
}

265
extensions/cross-agent.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* Cross-Agent — Load commands, skills, and agents from other AI coding agents
*
* Scans .claude/, .gemini/, .codex/ directories (project + global) for:
* commands/*.md → registered as /name
* skills/ → listed as /skill:name (discovery only)
* agents/*.md → listed as @name (discovery only)
*
* Usage: pi -e extensions/cross-agent.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir } from "node:os";
import { applyExtensionDefaults } from "./themeMap.ts";
import { wrapTextWithAnsi, visibleWidth } from "@mariozechner/pi-tui";
// --- Synthwave palette ---
function bg(s: string): string {
return `\x1b[48;2;52;20;58m${s}\x1b[49m`;
}
function pink(s: string): string {
return `\x1b[38;2;255;126;219m${s}\x1b[39m`;
}
function cyan(s: string): string {
return `\x1b[38;2;54;249;246m${s}\x1b[39m`;
}
function green(s: string): string {
return `\x1b[38;2;114;241;184m${s}\x1b[39m`;
}
function yellow(s: string): string {
return `\x1b[38;2;254;222;93m${s}\x1b[39m`;
}
function dim(s: string): string {
return `\x1b[38;2;120;100;140m${s}\x1b[39m`;
}
function bold(s: string): string {
return `\x1b[1m${s}\x1b[22m`;
}
interface Discovered {
name: string;
description: string;
content: string;
}
interface SourceGroup {
source: string;
commands: Discovered[];
skills: string[];
agents: Discovered[];
}
function parseFrontmatter(raw: string): { description: string; body: string; fields: Record<string, string> } {
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
if (!match) return { description: "", body: raw, fields: {} };
const front = match[1];
const body = match[2];
const fields: Record<string, string> = {};
for (const line of front.split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
return { description: fields.description || "", body, fields };
}
function expandArgs(template: string, args: string): string {
const parts = args.split(/\s+/).filter(Boolean);
let result = template;
result = result.replace(/\$ARGUMENTS|\$@/g, args);
for (let i = 0; i < parts.length; i++) {
result = result.replaceAll(`$${i + 1}`, parts[i]);
}
return result;
}
function scanCommands(dir: string): Discovered[] {
if (!existsSync(dir)) return [];
const items: Discovered[] = [];
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const raw = readFileSync(join(dir, file), "utf-8");
const { description, body } = parseFrontmatter(raw);
items.push({
name: basename(file, ".md"),
description: description || body.split("\n").find((l) => l.trim())?.trim() || "",
content: body,
});
}
} catch {}
return items;
}
function scanSkills(dir: string): string[] {
if (!existsSync(dir)) return [];
const names: string[] = [];
try {
for (const entry of readdirSync(dir)) {
const skillFile = join(dir, entry, "SKILL.md");
const flatFile = join(dir, entry);
if (existsSync(skillFile) && statSync(skillFile).isFile()) {
names.push(entry);
} else if (entry.endsWith(".md") && statSync(flatFile).isFile()) {
names.push(basename(entry, ".md"));
}
}
} catch {}
return names;
}
function scanAgents(dir: string): Discovered[] {
if (!existsSync(dir)) return [];
const items: Discovered[] = [];
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const raw = readFileSync(join(dir, file), "utf-8");
const { fields } = parseFrontmatter(raw);
items.push({
name: fields.name || basename(file, ".md"),
description: fields.description || "",
content: raw,
});
}
} catch {}
return items;
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
const home = homedir();
const cwd = ctx.cwd;
const providers = ["claude", "gemini", "codex"];
const groups: SourceGroup[] = [];
for (const p of providers) {
for (const [dir, label] of [
[join(cwd, `.${p}`), `.${p}`],
[join(home, `.${p}`), `~/.${p}`],
] as const) {
const commands = scanCommands(join(dir, "commands"));
const skills = scanSkills(join(dir, "skills"));
const agents = scanAgents(join(dir, "agents"));
if (commands.length || skills.length || agents.length) {
groups.push({ source: label, commands, skills, agents });
}
}
}
// Also scan .pi/agents/ (pi-vs-cc pattern)
const localAgents = scanAgents(join(cwd, ".pi", "agents"));
if (localAgents.length) {
groups.push({ source: ".pi/agents", commands: [], skills: [], agents: localAgents });
}
// Register commands
const seenCmds = new Set<string>();
let totalCommands = 0;
let totalSkills = 0;
let totalAgents = 0;
for (const g of groups) {
totalSkills += g.skills.length;
totalAgents += g.agents.length;
for (const cmd of g.commands) {
if (seenCmds.has(cmd.name)) continue;
seenCmds.add(cmd.name);
totalCommands++;
pi.registerCommand(cmd.name, {
description: `[${g.source}] ${cmd.description}`.slice(0, 120),
handler: async (args) => {
pi.sendUserMessage(expandArgs(cmd.content, args || ""));
},
});
}
}
if (groups.length === 0) return;
// We delay slightly so it doesn't get instantly overwritten by system-select's default startup notify
setTimeout(() => {
if (!ctx.hasUI) return;
// Reduce max width slightly to ensure it never overflows and breaks the next line
const width = Math.min((process.stdout.columns || 80) - 4, 100);
const pad = bg(" ".repeat(width));
const lines: string[] = [];
lines.push(""); // space from prev
for (let i = 0; i < groups.length; i++) {
const g = groups[i];
// Title with counts
const counts: string[] = [];
if (g.skills.length) counts.push(yellow("(") + green(`${g.skills.length}`) + dim(` skill${g.skills.length > 1 ? "s" : ""}`) + yellow(")"));
if (g.commands.length) counts.push(yellow("(") + green(`${g.commands.length}`) + dim(` command${g.commands.length > 1 ? "s" : ""}`) + yellow(")"));
if (g.agents.length) counts.push(yellow("(") + green(`${g.agents.length}`) + dim(` agent${g.agents.length > 1 ? "s" : ""}`) + yellow(")"));
const countStr = counts.length ? " " + counts.join(" ") : "";
lines.push(pink(bold(` ${g.source}`)) + countStr);
// Build body content
const items: string[] = [];
if (g.commands.length) {
items.push(
yellow("/") +
g.commands.map((c) => cyan(c.name)).join(yellow(", /"))
);
}
if (g.skills.length) {
items.push(
yellow("/skill:") +
g.skills.map((s) => cyan(s)).join(yellow(", /skill:"))
);
}
if (g.agents.length) {
items.push(
yellow("@") +
g.agents.map((a) => green(a.name)).join(yellow(", @"))
);
}
const body = items.join("\n");
// Top padding
lines.push(pad);
// Wrap body text, cap at 3 rows
const maxRows = 3;
const innerWidth = width - 4;
const wrapped = wrapTextWithAnsi(body, innerWidth);
const totalItems = g.commands.length + g.skills.length + g.agents.length;
const shown = wrapped.slice(0, maxRows);
for (const wline of shown) {
const vis = visibleWidth(wline);
const fill = Math.max(0, width - vis - 4);
lines.push(bg(" " + wline + " ".repeat(fill) + " "));
}
if (wrapped.length > maxRows) {
const overflow = dim(` ... ${totalItems - 15 > 0 ? totalItems - 15 : "more"} more`);
const oVis = visibleWidth(overflow);
const oFill = Math.max(0, width - oVis - 2);
lines.push(bg(overflow + " ".repeat(oFill) + " "));
}
// Bottom padding
lines.push(pad);
// Spacing between groups
if (i < groups.length - 1) lines.push("");
}
// We send it as "info" which forces it to be a raw text element in the chat
// without the widget container, but preserving all our ANSI colors!
ctx.ui.notify(lines.join("\n"), "info");
}, 100);
});
}

View File

@@ -0,0 +1,206 @@
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import { parse as yamlParse } from "yaml";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { applyExtensionDefaults } from "./themeMap.ts";
interface Rule {
pattern: string;
reason: string;
ask?: boolean;
}
interface Rules {
bashToolPatterns: Rule[];
zeroAccessPaths: string[];
readOnlyPaths: string[];
noDeletePaths: string[];
}
export default function (pi: ExtensionAPI) {
let rules: Rules = {
bashToolPatterns: [],
zeroAccessPaths: [],
readOnlyPaths: [],
noDeletePaths: [],
};
function resolvePath(p: string, cwd: string): string {
if (p.startsWith("~")) {
p = path.join(os.homedir(), p.slice(1));
}
return path.resolve(cwd, p);
}
function isPathMatch(targetPath: string, pattern: string, cwd: string): boolean {
// Simple glob-to-regex or substring match
// Expand tilde in pattern if present
const resolvedPattern = pattern.startsWith("~") ? path.join(os.homedir(), pattern.slice(1)) : pattern;
// If pattern ends with /, it's a directory match
if (resolvedPattern.endsWith("/")) {
const absolutePattern = path.isAbsolute(resolvedPattern) ? resolvedPattern : path.resolve(cwd, resolvedPattern);
return targetPath.startsWith(absolutePattern);
}
// Handle basic wildcards *
const regexPattern = resolvedPattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex chars
.replace(/\*/g, ".*"); // convert * to .*
const regex = new RegExp(`^${regexPattern}$|^${regexPattern}/|/${regexPattern}$|/${regexPattern}/`);
// Match against absolute path and relative-to-cwd path
const relativePath = path.relative(cwd, targetPath);
return regex.test(targetPath) || regex.test(relativePath) || targetPath.includes(resolvedPattern) || relativePath.includes(resolvedPattern);
}
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
const rulesPath = path.join(ctx.cwd, ".pi", "damage-control-rules.yaml");
try {
if (fs.existsSync(rulesPath)) {
const content = fs.readFileSync(rulesPath, "utf8");
const loaded = yamlParse(content) as Partial<Rules>;
rules = {
bashToolPatterns: loaded.bashToolPatterns || [],
zeroAccessPaths: loaded.zeroAccessPaths || [],
readOnlyPaths: loaded.readOnlyPaths || [],
noDeletePaths: loaded.noDeletePaths || [],
};
ctx.ui.notify(`🛡️ Damage-Control: Loaded ${rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length} rules.`);
} else {
ctx.ui.notify("🛡️ Damage-Control: No rules found at .pi/damage-control-rules.yaml");
}
} catch (err) {
ctx.ui.notify(`🛡️ Damage-Control: Failed to load rules: ${err instanceof Error ? err.message : String(err)}`);
}
ctx.ui.setStatus(`🛡️ Damage-Control Active: ${rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length} Rules`);
});
pi.on("tool_call", async (event, ctx) => {
let violationReason: string | null = null;
let shouldAsk = false;
// 1. Check Zero Access Paths for all tools that use path or glob
const checkPaths = (pathsToCheck: string[]) => {
for (const p of pathsToCheck) {
const resolved = resolvePath(p, ctx.cwd);
for (const zap of rules.zeroAccessPaths) {
if (isPathMatch(resolved, zap, ctx.cwd)) {
return `Access to zero-access path restricted: ${zap}`;
}
}
}
return null;
};
// Extract paths from tool input
const inputPaths: string[] = [];
if (isToolCallEventType("read", event) || isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
inputPaths.push(event.input.path);
} else if (isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event)) {
inputPaths.push(event.input.path || ".");
}
if (isToolCallEventType("grep", event) && event.input.glob) {
// Check glob field as well
for (const zap of rules.zeroAccessPaths) {
if (event.input.glob.includes(zap) || isPathMatch(event.input.glob, zap, ctx.cwd)) {
violationReason = `Glob matches zero-access path: ${zap}`;
break;
}
}
}
if (!violationReason) {
violationReason = checkPaths(inputPaths);
}
// 2. Tool-specific logic
if (!violationReason) {
if (isToolCallEventType("bash", event)) {
const command = event.input.command;
// Check bashToolPatterns
for (const rule of rules.bashToolPatterns) {
const regex = new RegExp(rule.pattern);
if (regex.test(command)) {
violationReason = rule.reason;
shouldAsk = !!rule.ask;
break;
}
}
// Check if bash command interacts with restricted paths
if (!violationReason) {
for (const zap of rules.zeroAccessPaths) {
if (command.includes(zap)) {
violationReason = `Bash command references zero-access path: ${zap}`;
break;
}
}
}
if (!violationReason) {
for (const rop of rules.readOnlyPaths) {
// Heuristic: check if command might modify a read-only path
// Redirects, sed -i, rm, mv to, etc.
if (command.includes(rop) && (/[\s>|]/.test(command) || command.includes("rm") || command.includes("mv") || command.includes("sed"))) {
violationReason = `Bash command may modify read-only path: ${rop}`;
break;
}
}
}
if (!violationReason) {
for (const ndp of rules.noDeletePaths) {
if (command.includes(ndp) && (command.includes("rm") || command.includes("mv"))) {
violationReason = `Bash command attempts to delete/move protected path: ${ndp}`;
break;
}
}
}
} else if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
// Check Read-Only paths
for (const p of inputPaths) {
const resolved = resolvePath(p, ctx.cwd);
for (const rop of rules.readOnlyPaths) {
if (isPathMatch(resolved, rop, ctx.cwd)) {
violationReason = `Modification of read-only path restricted: ${rop}`;
break;
}
}
}
}
}
if (violationReason) {
if (shouldAsk) {
const confirmed = await ctx.ui.confirm("🛡️ Damage-Control Confirmation", `Dangerous command detected: ${violationReason}\n\nCommand: ${isToolCallEventType("bash", event) ? event.input.command : JSON.stringify(event.input)}\n\nDo you want to proceed?`, { timeout: 30000 });
if (!confirmed) {
ctx.ui.setStatus(`⚠️ Last Violation Blocked: ${violationReason.slice(0, 30)}...`);
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "blocked_by_user" });
ctx.abort();
return { block: true, reason: `🛑 BLOCKED by Damage-Control: ${violationReason} (User denied)\n\nDO NOT attempt to work around this restriction. DO NOT retry with alternative commands, paths, or approaches that achieve the same result. Report this block to the user exactly as stated and ask how they would like to proceed.` };
} else {
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "confirmed_by_user" });
return { block: false };
}
} else {
ctx.ui.notify(`🛑 Damage-Control: Blocked ${event.toolName} due to ${violationReason}`);
ctx.ui.setStatus(`⚠️ Last Violation: ${violationReason.slice(0, 30)}...`);
pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "blocked" });
ctx.abort();
return { block: true, reason: `🛑 BLOCKED by Damage-Control: ${violationReason}\n\nDO NOT attempt to work around this restriction. DO NOT retry with alternative commands, paths, or approaches that achieve the same result. Report this block to the user exactly as stated and ask how they would like to proceed.` };
}
}
return { block: false };
});
}

34
extensions/minimal.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Minimal — Model name + context meter in a compact footer
*
* Shows model ID and a 10-block context usage bar: [###-------] 30%
*
* Usage: pi -e extensions/minimal.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { applyExtensionDefaults } from "./themeMap.ts";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
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 !== null) ? usage.percent : 0;
const filled = Math.round(pct / 10);
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const left = theme.fg("dim", ` ${model}`);
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
}));
});
}

633
extensions/pi-pi.ts Normal file
View File

@@ -0,0 +1,633 @@
/**
* 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)];
},
}));
});
}

24
extensions/pure-focus.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Pure Focus — Strip all footer and status line UI
*
* Removes the footer bar and status line entirely, leaving only
* the conversation and editor. Pure distraction-free mode.
*
* Usage: pi -e examples/extensions/pure-focus.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { applyExtensionDefaults } from "./themeMap.ts";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
ctx.ui.setFooter((_tui, _theme, _footerData) => ({
dispose: () => {},
invalidate() {},
render(_width: number): string[] {
return [];
},
}));
});
}

View File

@@ -0,0 +1,84 @@
/**
* Purpose Gate — Forces the engineer to declare intent before working
*
* On session start, immediately asks "What is the purpose of this agent?"
* via a text input dialog. A persistent widget shows the purpose for the
* rest of the session, keeping focus. Blocks all prompts until answered.
*
* Usage: pi -e extensions/purpose-gate.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./themeMap.ts";
// synthwave: bgWarm #4a1e6a → rgb(74,30,106)
function bg(s: string): string {
return `\x1b[48;2;74;30;106m${s}\x1b[49m`;
}
// synthwave: pink #ff7edb
function pink(s: string): string {
return `\x1b[38;2;255;126;219m${s}\x1b[39m`;
}
// synthwave: cyan #36f9f6
function cyan(s: string): string {
return `\x1b[38;2;54;249;246m${s}\x1b[39m`;
}
function bold(s: string): string {
return `\x1b[1m${s}\x1b[22m`;
}
export default function (pi: ExtensionAPI) {
let purpose: string | undefined;
async function askForPurpose(ctx: any) {
while (!purpose) {
const answer = await ctx.ui.input(
"What is the purpose of this agent?",
"e.g. Refactor the auth module to use JWT"
);
if (answer && answer.trim()) {
purpose = answer.trim();
} else {
ctx.ui.notify("Purpose is required.", "warning");
}
}
ctx.ui.setWidget("purpose", () => {
return {
render(width: number): string[] {
const pad = bg(" ".repeat(width));
const label = pink(bold(" PURPOSE: "));
const msg = cyan(bold(purpose!));
const content = bg(truncateToWidth(label + msg + " ".repeat(width), width, ""));
return [pad, content, pad];
},
invalidate() {},
};
});
}
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
void askForPurpose(ctx);
});
pi.on("before_agent_start", async (event) => {
if (!purpose) return;
return {
systemPrompt: event.systemPrompt + `\n\n<purpose>\nYour singular purpose this session: ${purpose}\nStay focused on this goal. If a request drifts from this purpose, gently remind the user.\n</purpose>`,
};
});
pi.on("input", async (_event, ctx) => {
if (!purpose) {
ctx.ui.notify("Set a purpose first.", "warning");
return { action: "handled" as const };
}
return { action: "continue" as const };
});
}

View File

@@ -0,0 +1,216 @@
import { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { applyExtensionDefaults } from "./themeMap.ts";
import {
Box, Text, Markdown, Container, Spacer,
matchesKey, Key, truncateToWidth, getMarkdownTheme
} from "@mariozechner/pi-tui";
import { DynamicBorder, getMarkdownTheme as getPiMdTheme } from "@mariozechner/pi-coding-agent";
// Minimal shim for timestamp handling if not directly in Message objects
function formatTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function getElapsedTime(start: Date, end: Date): string {
const diffMs = end.getTime() - start.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`;
const diffMin = Math.floor(diffSec / 60);
return `${diffMin}m ${diffSec % 60}s`;
}
interface HistoryItem {
type: 'user' | 'assistant' | 'tool';
title: string;
content: string;
timestamp: Date;
elapsed?: string;
}
class SessionReplayUI {
private selectedIndex = 0;
private expandedIndex: number | null = null;
private scrollOffset = 0;
constructor(
private items: HistoryItem[],
private onDone: () => void
) {
// Start selected at the bottom (most recent)
this.selectedIndex = Math.max(0, items.length - 1);
this.ensureVisible(20); // rough height estimate
}
handleInput(data: string, tui: any): void {
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
} else if (matchesKey(data, Key.enter)) {
this.expandedIndex = this.expandedIndex === this.selectedIndex ? null : this.selectedIndex;
} else if (matchesKey(data, Key.escape)) {
this.onDone();
return;
}
tui.requestRender();
}
private ensureVisible(height: number) {
// Simple scroll window logic
const pageSize = Math.floor(height / 3); // Approx items per page
if (this.selectedIndex < this.scrollOffset) {
this.scrollOffset = this.selectedIndex;
} else if (this.selectedIndex >= this.scrollOffset + pageSize) {
this.scrollOffset = this.selectedIndex - pageSize + 1;
}
}
render(width: number, height: number, theme: any): string[] {
this.ensureVisible(height);
const container = new Container();
const mdTheme = getPiMdTheme();
// Header
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(new Text(`${theme.fg("accent", theme.bold(" SESSION REPLAY"))} ${theme.fg("dim", "|")} ${theme.fg("success", this.items.length.toString())} entries`, 1, 0));
container.addChild(new Spacer(1));
// Calculate visible range
const visibleItems = this.items.slice(this.scrollOffset);
visibleItems.forEach((item, idx) => {
const absoluteIndex = idx + this.scrollOffset;
const isSelected = absoluteIndex === this.selectedIndex;
const isExpanded = absoluteIndex === this.expandedIndex;
const cardBox = new Box(1, 0, (s) => isSelected ? theme.bg("selectedBg", s) : s);
// Icon and Title
let icon = "○";
let color = "dim";
if (item.type === 'user') { icon = "👤"; color = "success"; }
else if (item.type === 'assistant') { icon = "🤖"; color = "accent"; }
else if (item.type === 'tool') { icon = "🛠️"; color = "warning"; }
const timeStr = theme.fg("success", `[${formatTime(item.timestamp)}]`);
const elapsedStr = item.elapsed ? theme.fg("dim", ` (+${item.elapsed})`) : "";
const titleLine = `${theme.fg(color, icon)} ${theme.bold(item.title)} ${timeStr}${elapsedStr}`;
cardBox.addChild(new Text(titleLine, 0, 0));
if (isExpanded) {
cardBox.addChild(new Spacer(1));
cardBox.addChild(new Markdown(item.content, 2, 0, mdTheme));
} else {
// Truncated preview
const preview = item.content.replace(/\n/g, ' ').substring(0, width - 10);
cardBox.addChild(new Text(theme.fg("dim", " " + preview + "..."), 0, 0));
}
container.addChild(cardBox);
// Don't add too many spacers if we have many items
if (visibleItems.length < 15) container.addChild(new Spacer(1));
});
// Footer
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", " ↑/↓ Navigate • Enter Expand • Esc Close"), 1, 0));
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return container.render(width);
}
}
function extractContent(entry: any): string {
const msg = entry.message;
if (!msg) return "";
const content = msg.content;
if (!content) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((c: any) => {
if (c.type === "text") return c.text || "";
if (c.type === "toolCall") return `Tool: ${c.name}(${JSON.stringify(c.arguments).slice(0, 200)})`;
return "";
})
.filter(Boolean)
.join("\n");
}
return JSON.stringify(content).slice(0, 500);
}
export default function(pi: ExtensionAPI) {
pi.registerCommand("replay", {
description: "Show a scrollable timeline of the current session",
handler: async (args, ctx) => {
const branch = ctx.sessionManager.getBranch();
const items: HistoryItem[] = [];
let prevTime: Date | null = null;
for (const entry of branch) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (!msg) continue;
const ts = msg.timestamp ? new Date(msg.timestamp) : new Date();
const elapsed = prevTime ? getElapsedTime(prevTime, ts) : undefined;
prevTime = ts;
const role = msg.role;
const text = extractContent(entry);
if (!text) continue;
if (role === "user") {
items.push({
type: "user",
title: "User Prompt",
content: text,
timestamp: ts,
elapsed,
});
} else if (role === "assistant") {
items.push({
type: "assistant",
title: "Assistant",
content: text,
timestamp: ts,
elapsed,
});
} else if (role === "toolResult") {
const toolName = (msg as any).toolName || "tool";
items.push({
type: "tool",
title: `Tool: ${toolName}`,
content: text,
timestamp: ts,
elapsed,
});
}
}
if (items.length === 0) {
ctx.ui.notify("No session history found.", "warning");
return;
}
await ctx.ui.custom((tui, theme, kb, done) => {
const component = new SessionReplayUI(items, () => done(undefined));
return {
render: (w) => component.render(w, 30, theme),
handleInput: (data) => component.handleInput(data, tui),
invalidate: () => {},
};
}, {
overlay: true,
overlayOptions: { width: "80%", anchor: "center" },
});
},
});
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
});
}

View File

@@ -0,0 +1,481 @@
/**
* Subagent Widget — /sub, /subclear, /subrm, /subcont commands with stacking live widgets
*
* Each /sub spawns a background Pi subagent with its own persistent session,
* enabling conversation continuations via /subcont.
*
* Usage: pi -e extensions/subagent-widget.ts
* Then:
* /sub list files and summarize — spawn a new subagent
* /subcont 1 now write tests for it — continue subagent #1's conversation
* /subrm 2 — remove subagent #2 widget
* /subclear — clear all subagent widgets
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
const { spawn } = require("child_process") as any;
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
interface SubState {
id: number;
status: "running" | "done" | "error";
task: string;
textChunks: string[];
toolCount: number;
elapsed: number;
sessionFile: string; // persistent JSONL session path — used by /subcont to resume
turnCount: number; // increments each time /subcont continues this agent
proc?: any; // active ChildProcess ref (for kill on /subrm)
}
export default function (pi: ExtensionAPI) {
const agents: Map<number, SubState> = new Map();
let nextId = 1;
let widgetCtx: any;
// ── Session file helpers ──────────────────────────────────────────────────
function makeSessionFile(id: number): string {
const dir = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents");
fs.mkdirSync(dir, { recursive: true });
return path.join(dir, `subagent-${id}-${Date.now()}.jsonl`);
}
// ── Widget rendering ──────────────────────────────────────────────────────
function updateWidgets() {
if (!widgetCtx) return;
for (const [id, state] of Array.from(agents.entries())) {
const key = `sub-${id}`;
widgetCtx.ui.setWidget(key, (_tui: any, theme: any) => {
const container = new Container();
const borderFn = (s: string) => theme.fg("dim", s);
container.addChild(new Text("", 0, 0)); // top margin
container.addChild(new DynamicBorder(borderFn));
const content = new Text("", 1, 0);
container.addChild(content);
container.addChild(new DynamicBorder(borderFn));
return {
render(width: number): string[] {
const lines: string[] = [];
const statusColor = state.status === "running" ? "accent"
: state.status === "done" ? "success" : "error";
const statusIcon = state.status === "running" ? "●"
: state.status === "done" ? "✓" : "✗";
const taskPreview = state.task.length > 40
? state.task.slice(0, 37) + "..."
: state.task;
const turnLabel = state.turnCount > 1
? theme.fg("dim", ` · Turn ${state.turnCount}`)
: "";
lines.push(
theme.fg(statusColor, `${statusIcon} Subagent #${state.id}`) +
turnLabel +
theme.fg("dim", ` ${taskPreview}`) +
theme.fg("dim", ` (${Math.round(state.elapsed / 1000)}s)`) +
theme.fg("dim", ` | Tools: ${state.toolCount}`)
);
const fullText = state.textChunks.join("");
const lastLine = fullText.split("\n").filter((l: string) => l.trim()).pop() || "";
if (lastLine) {
const trimmed = lastLine.length > width - 10
? lastLine.slice(0, width - 13) + "..."
: lastLine;
lines.push(theme.fg("muted", ` ${trimmed}`));
}
content.setText(lines.join("\n"));
return container.render(width);
},
invalidate() {
container.invalidate();
},
};
});
}
}
// ── Streaming helpers ─────────────────────────────────────────────────────
function processLine(state: SubState, line: string) {
if (!line.trim()) return;
try {
const event = JSON.parse(line);
const type = event.type;
if (type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") {
state.textChunks.push(delta.delta || "");
updateWidgets();
}
} else if (type === "tool_execution_start") {
state.toolCount++;
updateWidgets();
}
} catch {}
}
function spawnAgent(
state: SubState,
prompt: string,
ctx: any,
): Promise<void> {
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview";
return new Promise<void>((resolve) => {
const proc = spawn("pi", [
"--mode", "json",
"-p",
"--session", state.sessionFile, // persistent session for /subcont resumption
"--no-extensions",
"--model", model,
"--tools", "read,bash,grep,find,ls",
"--thinking", "off",
prompt,
], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
state.proc = proc;
const startTime = Date.now();
const timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
updateWidgets();
}, 1000);
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) processLine(state, line);
});
proc.stderr!.setEncoding("utf-8");
proc.stderr!.on("data", (chunk: string) => {
if (chunk.trim()) {
state.textChunks.push(chunk);
updateWidgets();
}
});
proc.on("close", (code) => {
if (buffer.trim()) processLine(state, buffer);
clearInterval(timer);
state.elapsed = Date.now() - startTime;
state.status = code === 0 ? "done" : "error";
state.proc = undefined;
updateWidgets();
const result = state.textChunks.join("");
ctx.ui.notify(
`Subagent #${state.id} ${state.status} in ${Math.round(state.elapsed / 1000)}s`,
state.status === "done" ? "success" : "error"
);
pi.sendMessage({
customType: "subagent-result",
content: `Subagent #${state.id}${state.turnCount > 1 ? ` (Turn ${state.turnCount})` : ""} finished "${prompt}" in ${Math.round(state.elapsed / 1000)}s.\n\nResult:\n${result.slice(0, 8000)}${result.length > 8000 ? "\n\n... [truncated]" : ""}`,
display: true,
}, { deliverAs: "followUp", triggerTurn: true });
resolve();
});
proc.on("error", (err) => {
clearInterval(timer);
state.status = "error";
state.proc = undefined;
state.textChunks.push(`Error: ${err.message}`);
updateWidgets();
resolve();
});
});
}
// ── Tools for the Main Agent ──────────────────────────────────────────────
pi.registerTool({
name: "subagent_create",
description: "Spawn a background subagent to perform a task. Returns the subagent ID immediately while it runs in the background. Results will be delivered as a follow-up message when finished.",
parameters: Type.Object({
task: Type.String({ description: "The complete task description for the subagent to perform" }),
}),
execute: async (callId, args, _signal, _onUpdate, ctx) => {
widgetCtx = ctx;
const id = nextId++;
const state: SubState = {
id,
status: "running",
task: args.task,
textChunks: [],
toolCount: 0,
elapsed: 0,
sessionFile: makeSessionFile(id),
turnCount: 1,
};
agents.set(id, state);
updateWidgets();
// Fire-and-forget
spawnAgent(state, args.task, ctx);
return {
content: [{ type: "text", text: `Subagent #${id} spawned and running in background.` }],
};
},
});
pi.registerTool({
name: "subagent_continue",
description: "Continue an existing subagent's conversation. Use this to give further instructions to a finished subagent. Returns immediately while it runs in the background.",
parameters: Type.Object({
id: Type.Number({ description: "The ID of the subagent to continue" }),
prompt: Type.String({ description: "The follow-up prompt or new instructions" }),
}),
execute: async (callId, args, _signal, _onUpdate, ctx) => {
widgetCtx = ctx;
const state = agents.get(args.id);
if (!state) {
return { content: [{ type: "text", text: `Error: No subagent #${args.id} found.` }] };
}
if (state.status === "running") {
return { content: [{ type: "text", text: `Error: Subagent #${args.id} is still running.` }] };
}
state.status = "running";
state.task = args.prompt;
state.textChunks = [];
state.elapsed = 0;
state.turnCount++;
updateWidgets();
ctx.ui.notify(`Continuing Subagent #${args.id} (Turn ${state.turnCount})…`, "info");
spawnAgent(state, args.prompt, ctx);
return {
content: [{ type: "text", text: `Subagent #${args.id} continuing conversation in background.` }],
};
},
});
pi.registerTool({
name: "subagent_remove",
description: "Remove a specific subagent. Kills it if it's currently running.",
parameters: Type.Object({
id: Type.Number({ description: "The ID of the subagent to remove" }),
}),
execute: async (callId, args, _signal, _onUpdate, ctx) => {
widgetCtx = ctx;
const state = agents.get(args.id);
if (!state) {
return { content: [{ type: "text", text: `Error: No subagent #${args.id} found.` }] };
}
if (state.proc && state.status === "running") {
state.proc.kill("SIGTERM");
}
ctx.ui.setWidget(`sub-${args.id}`, undefined);
agents.delete(args.id);
return {
content: [{ type: "text", text: `Subagent #${args.id} removed successfully.` }],
};
},
});
pi.registerTool({
name: "subagent_list",
description: "List all active and finished subagents, showing their IDs, tasks, and status.",
parameters: Type.Object({}),
execute: async () => {
if (agents.size === 0) {
return { content: [{ type: "text", text: "No active subagents." }] };
}
const list = Array.from(agents.values()).map(s =>
`#${s.id} [${s.status.toUpperCase()}] (Turn ${s.turnCount}) - ${s.task}`
).join("\n");
return {
content: [{ type: "text", text: `Subagents:\n${list}` }],
};
},
});
// ── /sub <task> ───────────────────────────────────────────────────────────
pi.registerCommand("sub", {
description: "Spawn a subagent with live widget: /sub <task>",
handler: async (args, ctx) => {
widgetCtx = ctx;
const task = args?.trim();
if (!task) {
ctx.ui.notify("Usage: /sub <task>", "error");
return;
}
const id = nextId++;
const state: SubState = {
id,
status: "running",
task,
textChunks: [],
toolCount: 0,
elapsed: 0,
sessionFile: makeSessionFile(id),
turnCount: 1,
};
agents.set(id, state);
updateWidgets();
// Fire-and-forget
spawnAgent(state, task, ctx);
},
});
// ── /subcont <number> <prompt> ────────────────────────────────────────────
pi.registerCommand("subcont", {
description: "Continue an existing subagent's conversation: /subcont <number> <prompt>",
handler: async (args, ctx) => {
widgetCtx = ctx;
const trimmed = args?.trim() ?? "";
const spaceIdx = trimmed.indexOf(" ");
if (spaceIdx === -1) {
ctx.ui.notify("Usage: /subcont <number> <prompt>", "error");
return;
}
const num = parseInt(trimmed.slice(0, spaceIdx), 10);
const prompt = trimmed.slice(spaceIdx + 1).trim();
if (isNaN(num) || !prompt) {
ctx.ui.notify("Usage: /subcont <number> <prompt>", "error");
return;
}
const state = agents.get(num);
if (!state) {
ctx.ui.notify(`No subagent #${num} found. Use /sub to create one.`, "error");
return;
}
if (state.status === "running") {
ctx.ui.notify(`Subagent #${num} is still running — wait for it to finish first.`, "warning");
return;
}
// Resume: update state for a new turn
state.status = "running";
state.task = prompt;
state.textChunks = [];
state.elapsed = 0;
state.turnCount++;
updateWidgets();
ctx.ui.notify(`Continuing Subagent #${num} (Turn ${state.turnCount})…`, "info");
// Fire-and-forget — reuses the same sessionFile for conversation history
spawnAgent(state, prompt, ctx);
},
});
// ── /subrm <number> ───────────────────────────────────────────────────────
pi.registerCommand("subrm", {
description: "Remove a specific subagent widget: /subrm <number>",
handler: async (args, ctx) => {
widgetCtx = ctx;
const num = parseInt(args?.trim() ?? "", 10);
if (isNaN(num)) {
ctx.ui.notify("Usage: /subrm <number>", "error");
return;
}
const state = agents.get(num);
if (!state) {
ctx.ui.notify(`No subagent #${num} found.`, "error");
return;
}
// Kill the process if still running
if (state.proc && state.status === "running") {
state.proc.kill("SIGTERM");
ctx.ui.notify(`Subagent #${num} killed and removed.`, "warning");
} else {
ctx.ui.notify(`Subagent #${num} removed.`, "info");
}
ctx.ui.setWidget(`sub-${num}`, undefined);
agents.delete(num);
},
});
// ── /subclear ─────────────────────────────────────────────────────────────
pi.registerCommand("subclear", {
description: "Clear all subagent widgets",
handler: async (_args, ctx) => {
widgetCtx = ctx;
let killed = 0;
for (const [id, state] of Array.from(agents.entries())) {
if (state.proc && state.status === "running") {
state.proc.kill("SIGTERM");
killed++;
}
ctx.ui.setWidget(`sub-${id}`, undefined);
}
const total = agents.size;
agents.clear();
nextId = 1;
const msg = total === 0
? "No subagents to clear."
: `Cleared ${total} subagent${total !== 1 ? "s" : ""}${killed > 0 ? ` (${killed} killed)` : ""}.`;
ctx.ui.notify(msg, total === 0 ? "info" : "success");
},
});
// ── Session lifecycle ─────────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
for (const [id, state] of Array.from(agents.entries())) {
if (state.proc && state.status === "running") {
state.proc.kill("SIGTERM");
}
ctx.ui.setWidget(`sub-${id}`, undefined);
}
agents.clear();
nextId = 1;
widgetCtx = ctx;
});
}

167
extensions/system-select.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* System Select — Switch the system prompt via /system
*
* Scans .pi/agents/, .claude/agents/, .gemini/agents/, .codex/agents/
* (project-local and global) for agent definition .md files.
*
* /system opens a select dialog to pick a system prompt. The selected
* agent's body is prepended to Pi's default instructions so tool usage
* still works. Tools are restricted to the agent's declared tool set
* if specified.
*
* Usage: pi -e extensions/system-select.ts -e extensions/minimal.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir } from "node:os";
import { applyExtensionDefaults } from "./themeMap.ts";
interface AgentDef {
name: string;
description: string;
tools: string[];
body: string;
source: string;
}
function parseFrontmatter(raw: string): { fields: Record<string, string>; body: string } {
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
if (!match) return { fields: {}, body: raw };
const fields: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
return { fields, body: match[2] };
}
function scanAgents(dir: string, source: string): AgentDef[] {
if (!existsSync(dir)) return [];
const agents: AgentDef[] = [];
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const raw = readFileSync(join(dir, file), "utf-8");
const { fields, body } = parseFrontmatter(raw);
agents.push({
name: fields.name || basename(file, ".md"),
description: fields.description || "",
tools: fields.tools ? fields.tools.split(",").map((t) => t.trim()) : [],
body: body.trim(),
source,
});
}
} catch {}
return agents;
}
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
export default function (pi: ExtensionAPI) {
let activeAgent: AgentDef | null = null;
let allAgents: AgentDef[] = [];
let defaultTools: string[] = [];
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
activeAgent = null;
allAgents = [];
const home = homedir();
const cwd = ctx.cwd;
const dirs: [string, string][] = [
[join(cwd, ".pi", "agents"), ".pi"],
[join(cwd, ".claude", "agents"), ".claude"],
[join(cwd, ".gemini", "agents"), ".gemini"],
[join(cwd, ".codex", "agents"), ".codex"],
[join(home, ".pi", "agent", "agents"), "~/.pi"],
[join(home, ".claude", "agents"), "~/.claude"],
[join(home, ".gemini", "agents"), "~/.gemini"],
[join(home, ".codex", "agents"), "~/.codex"],
];
const seen = new Set<string>();
const sourceCounts: Record<string, number> = {};
for (const [dir, source] of dirs) {
const agents = scanAgents(dir, source);
for (const agent of agents) {
const key = agent.name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
allAgents.push(agent);
sourceCounts[source] = (sourceCounts[source] || 0) + 1;
}
}
defaultTools = pi.getActiveTools();
ctx.ui.setStatus("system-prompt", "System Prompt: Default");
const defaultPrompt = ctx.getSystemPrompt();
const lines = defaultPrompt.split("\n").length;
const chars = defaultPrompt.length;
const loadedSources = Object.entries(sourceCounts)
.map(([src, count]) => `${count} from ${src}`)
.join(", ");
const notifyLines = [];
if (allAgents.length > 0) {
notifyLines.push(`Loaded ${allAgents.length} agents (${loadedSources})`);
}
notifyLines.push(`System Prompt: Default (${lines} lines, ${chars} chars)`);
ctx.ui.notify(notifyLines.join("\n"), "info");
});
pi.registerCommand("system", {
description: "Select a system prompt from discovered agents",
handler: async (_args, ctx) => {
if (allAgents.length === 0) {
ctx.ui.notify("No agents found in .*/agents/*.md", "warning");
return;
}
const options = [
"Reset to Default",
...allAgents.map((a) => `${a.name}${a.description} [${a.source}]`),
];
const choice = await ctx.ui.select("Select System Prompt", options);
if (choice === undefined) return;
if (choice === options[0]) {
activeAgent = null;
pi.setActiveTools(defaultTools);
ctx.ui.setStatus("system-prompt", "System Prompt: Default");
ctx.ui.notify("System Prompt reset to Default", "success");
return;
}
const idx = options.indexOf(choice) - 1;
const agent = allAgents[idx];
activeAgent = agent;
if (agent.tools.length > 0) {
pi.setActiveTools(agent.tools);
} else {
pi.setActiveTools(defaultTools);
}
ctx.ui.setStatus("system-prompt", `System Prompt: ${displayName(agent.name)}`);
ctx.ui.notify(`System Prompt switched to: ${displayName(agent.name)}`, "success");
},
});
pi.on("before_agent_start", async (event, _ctx) => {
if (!activeAgent) return;
return {
systemPrompt: activeAgent.body + "\n\n" + event.systemPrompt,
};
});
}

181
extensions/theme-cycler.ts Normal file
View File

@@ -0,0 +1,181 @@
/**
* Theme Cycler — Keyboard shortcuts to cycle through available themes
*
* Shortcuts:
* Ctrl+X — Cycle theme forward
* Ctrl+Q — Cycle theme backward
*
* Commands:
* /theme — Open select picker to choose a theme
* /theme <name> — Switch directly by name
*
* Features:
* - Status line shows current theme name with accent color
* - Color swatch widget flashes briefly after each switch
* - Auto-dismisses swatch after 3 seconds
*
* Usage: pi -e extensions/theme-cycler.ts -e extensions/minimal.ts
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateToWidth } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./themeMap.ts";
export default function (pi: ExtensionAPI) {
let currentCtx: ExtensionContext | undefined;
let swatchTimer: ReturnType<typeof setTimeout> | null = null;
function updateStatus(ctx: ExtensionContext) {
if (!ctx.hasUI) return;
const name = ctx.ui.theme.name;
ctx.ui.setStatus("theme", `🎨 ${name}`);
}
function showSwatch(ctx: ExtensionContext) {
if (!ctx.hasUI) return;
if (swatchTimer) {
clearTimeout(swatchTimer);
swatchTimer = null;
}
ctx.ui.setWidget(
"theme-swatch",
(_tui, theme) => ({
invalidate() {},
render(width: number): string[] {
const block = "\u2588\u2588\u2588";
const swatch =
theme.fg("success", block) +
" " +
theme.fg("accent", block) +
" " +
theme.fg("warning", block) +
" " +
theme.fg("dim", block) +
" " +
theme.fg("muted", block);
const label = theme.fg("accent", " 🎨 ") + theme.fg("muted", ctx.ui.theme.name) + " " + swatch;
const border = theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
return [border, truncateToWidth(" " + label, width), border];
},
}),
{ placement: "belowEditor" },
);
swatchTimer = setTimeout(() => {
ctx.ui.setWidget("theme-swatch", undefined);
swatchTimer = null;
}, 3000);
}
function getThemeList(ctx: ExtensionContext) {
return ctx.ui.getAllThemes();
}
function findCurrentIndex(ctx: ExtensionContext): number {
const themes = getThemeList(ctx);
const current = ctx.ui.theme.name;
return themes.findIndex((t) => t.name === current);
}
function cycleTheme(ctx: ExtensionContext, direction: 1 | -1) {
if (!ctx.hasUI) return;
const themes = getThemeList(ctx);
if (themes.length === 0) {
ctx.ui.notify("No themes available", "warning");
return;
}
let index = findCurrentIndex(ctx);
if (index === -1) index = 0;
index = (index + direction + themes.length) % themes.length;
const theme = themes[index];
const result = ctx.ui.setTheme(theme.name);
if (result.success) {
updateStatus(ctx);
showSwatch(ctx);
ctx.ui.notify(`${theme.name} (${index + 1}/${themes.length})`, "info");
} else {
ctx.ui.notify(`Failed to set theme: ${result.error}`, "error");
}
}
// --- Shortcuts ---
pi.registerShortcut("ctrl+x", {
description: "Cycle theme forward",
handler: async (ctx) => {
currentCtx = ctx;
cycleTheme(ctx, 1);
},
});
pi.registerShortcut("ctrl+q", {
description: "Cycle theme backward",
handler: async (ctx) => {
currentCtx = ctx;
cycleTheme(ctx, -1);
},
});
// --- Command: /theme ---
pi.registerCommand("theme", {
description: "Select a theme: /theme or /theme <name>",
handler: async (args, ctx) => {
currentCtx = ctx;
if (!ctx.hasUI) return;
const themes = getThemeList(ctx);
const arg = args.trim();
if (arg) {
const result = ctx.ui.setTheme(arg);
if (result.success) {
updateStatus(ctx);
showSwatch(ctx);
ctx.ui.notify(`Theme: ${arg}`, "info");
} else {
ctx.ui.notify(`Theme not found: ${arg}. Use /theme to see available themes.`, "error");
}
return;
}
const items = themes.map((t) => {
const desc = t.path ? t.path : "built-in";
const active = t.name === ctx.ui.theme.name ? " (active)" : "";
return `${t.name}${active}${desc}`;
});
const selected = await ctx.ui.select("Select Theme", items);
if (!selected) return;
const selectedName = selected.split(/\s/)[0];
const result = ctx.ui.setTheme(selectedName);
if (result.success) {
updateStatus(ctx);
showSwatch(ctx);
ctx.ui.notify(`Theme: ${selectedName}`, "info");
}
},
});
// --- Session init ---
pi.on("session_start", async (_event, ctx) => {
currentCtx = ctx;
applyExtensionDefaults(import.meta.url, ctx);
updateStatus(ctx);
});
pi.on("session_shutdown", async () => {
if (swatchTimer) {
clearTimeout(swatchTimer);
swatchTimer = null;
}
});
}

143
extensions/themeMap.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* themeMap.ts — Per-extension default theme assignments
*
* Themes live in .pi/themes/ and are mapped by extension filename (no extension).
* Each extension calls applyExtensionTheme(import.meta.url, ctx) in its session_start
* hook to automatically load its designated theme on boot.
*
* Available themes (.pi/themes/):
* catppuccin-mocha · cyberpunk · dracula · everforest · gruvbox
* midnight-ocean · nord · ocean-breeze · rose-pine
* synthwave · tokyo-night
*/
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { basename } from "path";
import { fileURLToPath } from "url";
// ── Theme assignments ──────────────────────────────────────────────────────
//
// Key = extension filename without extension (matches extensions/<key>.ts)
// Value = theme name from .pi/themes/<value>.json
//
export const THEME_MAP: Record<string, string> = {
"agent-chain": "midnight-ocean", // deep sequential pipeline
"agent-team": "dracula", // rich orchestration palette
"cross-agent": "ocean-breeze", // cross-boundary, connecting
"damage-control": "gruvbox", // grounded, earthy safety
"minimal": "synthwave", // synthwave by default now!
"pi-pi": "rose-pine", // warm creative meta-agent
"pure-focus": "everforest", // calm, distraction-free
"purpose-gate": "tokyo-night", // intentional, sharp focus
"session-replay": "catppuccin-mocha", // soft, reflective history
"subagent-widget": "cyberpunk", // multi-agent futuristic
"system-select": "catppuccin-mocha", // soft selection UI
"theme-cycler": "synthwave", // neon, it's a theme tool
"tilldone": "everforest", // task-focused calm
"tool-counter": "synthwave", // techy metrics
"tool-counter-widget":"synthwave", // same family
};
// ── Helpers ───────────────────────────────────────────────────────────────
/** Derive the extension name (e.g. "minimal") from its import.meta.url. */
function extensionName(fileUrl: string): string {
const filePath = fileUrl.startsWith("file://") ? fileURLToPath(fileUrl) : fileUrl;
return basename(filePath).replace(/\.[^.]+$/, "");
}
// ── Theme ──────────────────────────────────────────────────────────────────
/**
* Apply the mapped theme for an extension on session boot.
*
* @param fileUrl Pass `import.meta.url` from the calling extension file.
* @param ctx The ExtensionContext from the session_start handler.
* @returns true if the theme was applied successfully, false otherwise.
*/
export function applyExtensionTheme(fileUrl: string, ctx: ExtensionContext): boolean {
if (!ctx.hasUI) return false;
const name = extensionName(fileUrl);
// If there are multiple extensions stacked in 'ipi', they each fire session_start
// and try to apply their own mapped theme. The LAST one to fire wins.
// Since system-select is last in the ipi alias array, it was setting 'catppuccin-mocha'.
// We want to skip theme application for all secondary extensions if they are stacked,
// so the primary extension (first in the array) dictates the theme.
const primaryExt = primaryExtensionName();
if (primaryExt && primaryExt !== name) {
return true; // Pretend we succeeded, but don't overwrite the primary theme
}
let themeName = THEME_MAP[name];
if (!themeName) {
themeName = "synthwave";
}
const result = ctx.ui.setTheme(themeName);
if (!result.success && themeName !== "synthwave") {
return ctx.ui.setTheme("synthwave").success;
}
return result.success;
}
// ── Title ──────────────────────────────────────────────────────────────────
/**
* Read process.argv to find the first -e / --extension flag value.
*
* When Pi is launched as:
* pi -e extensions/subagent-widget.ts -e extensions/pure-focus.ts
*
* process.argv contains those paths verbatim. Every stacked extension calls
* this and gets the same answer ("subagent-widget"), so all setTitle calls
* are idempotent — no shared state or deduplication needed.
*
* Returns null if no -e flag is present (e.g. plain `pi` with no extensions).
*/
function primaryExtensionName(): string | null {
const argv = process.argv;
for (let i = 0; i < argv.length - 1; i++) {
if (argv[i] === "-e" || argv[i] === "--extension") {
return basename(argv[i + 1]).replace(/\.[^.]+$/, "");
}
}
return null;
}
/**
* Set the terminal title to "π - <first-extension-name>" on session boot.
* Reads the title from process.argv so all stacked extensions agree on the
* same value — no coordination or shared state required.
*
* Deferred 150 ms to fire after Pi's own startup title-set.
*/
function applyExtensionTitle(ctx: ExtensionContext): void {
if (!ctx.hasUI) return;
const name = primaryExtensionName();
if (!name) return;
setTimeout(() => ctx.ui.setTitle(`π - ${name}`), 150);
}
// ── Combined default ───────────────────────────────────────────────────────
/**
* Apply both the mapped theme AND the terminal title for an extension.
* Drop-in replacement for applyExtensionTheme — call this in every session_start.
*
* Usage:
* import { applyExtensionDefaults } from "./themeMap.ts";
*
* pi.on("session_start", async (_event, ctx) => {
* applyExtensionDefaults(import.meta.url, ctx);
* // ... rest of handler
* });
*/
export function applyExtensionDefaults(fileUrl: string, ctx: ExtensionContext): void {
applyExtensionTheme(fileUrl, ctx);
applyExtensionTitle(ctx);
}

726
extensions/tilldone.ts Normal file
View File

@@ -0,0 +1,726 @@
/**
* TillDone Extension — Work Till It's Done
*
* A task-driven discipline extension. The agent MUST define what it's going
* to do (via `tilldone add`) before it can use any other tools. On agent
* completion, if tasks remain incomplete, the agent gets nudged to continue
* or mark them done. Play on words: "todo" → "tilldone" (work till done).
*
* Three-state lifecycle: idle → inprogress → done
*
* Each list has a title and description that give the tasks a theme.
* Use `new-list` to start a fresh list. `clear` wipes tasks with user confirm.
*
* UI surfaces:
* - Footer: persistent task list with live progress + list title
* - Widget: prominent "current task" display (the inprogress task)
* - Status: compact summary in the status line
* - /tilldone: interactive overlay with full task details
*
* Usage: pi -e extensions/tilldone.ts
*/
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import { Container, matchesKey, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Types ──────────────────────────────────────────────────────────────
type TaskStatus = "idle" | "inprogress" | "done";
interface Task {
id: number;
text: string;
status: TaskStatus;
}
interface TillDoneDetails {
action: string;
tasks: Task[];
nextId: number;
listTitle?: string;
listDescription?: string;
error?: string;
}
const TillDoneParams = Type.Object({
action: StringEnum(["new-list", "add", "toggle", "remove", "update", "list", "clear"] as const),
text: Type.Optional(Type.String({ description: "Task text (for add/update), or list title (for new-list)" })),
texts: Type.Optional(Type.Array(Type.String(), { description: "Multiple task texts (for add). Use this to batch-add several tasks at once." })),
description: Type.Optional(Type.String({ description: "List description (for new-list)" })),
id: Type.Optional(Type.Number({ description: "Task ID (for toggle/remove/update)" })),
});
// ── Status helpers ─────────────────────────────────────────────────────
const STATUS_ICON: Record<TaskStatus, string> = { idle: "○", inprogress: "●", done: "✓" };
const NEXT_STATUS: Record<TaskStatus, TaskStatus> = { idle: "inprogress", inprogress: "done", done: "idle" };
const STATUS_LABEL: Record<TaskStatus, string> = { idle: "idle", inprogress: "in progress", done: "done" };
// ── /tilldone overlay component ────────────────────────────────────────
class TillDoneListComponent {
private tasks: Task[];
private title: string | undefined;
private desc: string | undefined;
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(tasks: Task[], title: string | undefined, desc: string | undefined, theme: Theme, onClose: () => void) {
this.tasks = tasks;
this.title = title;
this.desc = desc;
this.theme = theme;
this.onClose = onClose;
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.onClose();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
const lines: string[] = [];
const th = this.theme;
lines.push("");
const heading = this.title
? th.fg("accent", ` ${this.title} `)
: th.fg("accent", " TillDone ");
const headingLen = this.title ? this.title.length + 2 : 10;
lines.push(truncateToWidth(
th.fg("borderMuted", "─".repeat(3)) + heading +
th.fg("borderMuted", "─".repeat(Math.max(0, width - 3 - headingLen))),
width,
));
if (this.desc) {
lines.push(truncateToWidth(` ${th.fg("muted", this.desc)}`, width));
}
lines.push("");
if (this.tasks.length === 0) {
lines.push(truncateToWidth(` ${th.fg("dim", "No tasks yet. Ask the agent to add some!")}`, width));
} else {
const done = this.tasks.filter((t) => t.status === "done").length;
const active = this.tasks.filter((t) => t.status === "inprogress").length;
const idle = this.tasks.filter((t) => t.status === "idle").length;
lines.push(truncateToWidth(
" " +
th.fg("success", `${done} done`) + th.fg("dim", " ") +
th.fg("accent", `${active} active`) + th.fg("dim", " ") +
th.fg("muted", `${idle} idle`),
width,
));
lines.push("");
for (const task of this.tasks) {
const icon = task.status === "done"
? th.fg("success", STATUS_ICON.done)
: task.status === "inprogress"
? th.fg("accent", STATUS_ICON.inprogress)
: th.fg("dim", STATUS_ICON.idle);
const id = th.fg("accent", `#${task.id}`);
const text = task.status === "done"
? th.fg("dim", task.text)
: task.status === "inprogress"
? th.fg("success", task.text)
: th.fg("muted", task.text);
lines.push(truncateToWidth(` ${icon} ${id} ${text}`, width));
}
}
lines.push("");
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
lines.push("");
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
// ── Extension entry point ──────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
let tasks: Task[] = [];
let nextId = 1;
let listTitle: string | undefined;
let listDescription: string | undefined;
let nudgedThisCycle = false;
// ── Snapshot for details ───────────────────────────────────────────
const makeDetails = (action: string, error?: string): TillDoneDetails => ({
action,
tasks: [...tasks],
nextId,
listTitle,
listDescription,
...(error ? { error } : {}),
});
// ── UI refresh ─────────────────────────────────────────────────────
const refreshWidget = (ctx: ExtensionContext) => {
const current = tasks.find((t) => t.status === "inprogress");
if (!current) {
ctx.ui.setWidget("tilldone-current", undefined);
return;
}
ctx.ui.setWidget("tilldone-current", (_tui, theme) => {
const container = new Container();
const borderFn = (s: string) => theme.fg("dim", s);
container.addChild(new Text("", 0, 0));
container.addChild(new DynamicBorder(borderFn));
const content = new Text("", 1, 0);
container.addChild(content);
container.addChild(new DynamicBorder(borderFn));
return {
render(width: number): string[] {
const cur = tasks.find((t) => t.status === "inprogress");
if (!cur) return [];
const line =
theme.fg("accent", "● ") +
theme.fg("dim", "WORKING ON ") +
theme.fg("accent", `#${cur.id}`) +
theme.fg("dim", " ") +
theme.fg("success", cur.text);
content.setText(truncateToWidth(line, width - 4));
return container.render(width);
},
invalidate() { container.invalidate(); },
};
}, { placement: "belowEditor" });
};
const refreshFooter = (ctx: ExtensionContext) => {
ctx.ui.setFooter((tui, theme, footerData) => {
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
const done = tasks.filter((t) => t.status === "done").length;
const active = tasks.filter((t) => t.status === "inprogress").length;
const idle = tasks.filter((t) => t.status === "idle").length;
const total = tasks.length;
// ── Line 1: list title + progress (left), counts (right) ──
const titleDisplay = listTitle
? theme.fg("accent", ` ${listTitle} `)
: theme.fg("dim", " TillDone ");
const l1Left = total === 0
? titleDisplay + theme.fg("muted", "no tasks")
: titleDisplay +
theme.fg("warning", "[") +
theme.fg("success", `${done}`) +
theme.fg("dim", "/") +
theme.fg("success", `${total}`) +
theme.fg("warning", "]");
const l1Right = total === 0
? ""
: theme.fg("dim", STATUS_ICON.idle + " ") + theme.fg("muted", `${idle}`) +
theme.fg("dim", " ") +
theme.fg("accent", STATUS_ICON.inprogress + " ") + theme.fg("accent", `${active}`) +
theme.fg("dim", " ") +
theme.fg("success", STATUS_ICON.done + " ") + theme.fg("success", `${done}`) +
theme.fg("dim", " ");
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right)));
const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, "");
if (total === 0) return [line1];
// ── Rows: inprogress first, then most recent done, max 5 ──
const activeTasks = tasks.filter((t) => t.status === "inprogress");
const doneTasks = tasks.filter((t) => t.status === "done").reverse();
const visible = [...activeTasks, ...doneTasks].slice(0, 5);
const remaining = total - visible.length;
const rows = visible.map((t) => {
const icon = t.status === "done"
? theme.fg("success", STATUS_ICON.done)
: theme.fg("accent", STATUS_ICON.inprogress);
const text = t.status === "done"
? theme.fg("dim", t.text)
: theme.fg("success", t.text);
return truncateToWidth(` ${icon} ${text}`, width, "");
});
if (remaining > 0) {
rows.push(truncateToWidth(
` ${theme.fg("dim", ` +${remaining} more`)}`,
width, "",
));
}
return [line1, ...rows];
},
};
});
};
const refreshUI = (ctx: ExtensionContext) => {
if (tasks.length === 0) {
ctx.ui.setStatus("📋 TillDone: no tasks", "tilldone");
} else {
const remaining = tasks.filter((t) => t.status !== "done").length;
const label = listTitle ? `📋 ${listTitle}` : "📋 TillDone";
ctx.ui.setStatus(`${label}: ${tasks.length} tasks (${remaining} remaining)`, "tilldone");
}
refreshWidget(ctx);
refreshFooter(ctx);
};
// ── State reconstruction from session ──────────────────────────────
const reconstructState = (ctx: ExtensionContext) => {
tasks = [];
nextId = 1;
listTitle = undefined;
listDescription = undefined;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role !== "toolResult" || msg.toolName !== "tilldone") continue;
const details = msg.details as TillDoneDetails | undefined;
if (details) {
tasks = details.tasks;
nextId = details.nextId;
listTitle = details.listTitle;
listDescription = details.listDescription;
}
}
refreshUI(ctx);
};
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
reconstructState(ctx);
});
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// ── Blocking gate ──────────────────────────────────────────────────
pi.on("tool_call", async (event, _ctx) => {
if (event.toolName === "tilldone") return { block: false };
const pending = tasks.filter((t) => t.status !== "done");
const active = tasks.filter((t) => t.status === "inprogress");
if (tasks.length === 0) {
return {
block: true,
reason: "🚫 No TillDone tasks defined. You MUST use `tilldone new-list` or `tilldone add` to define your tasks before using any other tools. Plan your work first!",
};
}
if (pending.length === 0) {
return {
block: true,
reason: "🚫 All TillDone tasks are done. You MUST use `tilldone add` for new tasks or `tilldone new-list` to start a fresh list before using any other tools.",
};
}
if (active.length === 0) {
return {
block: true,
reason: "🚫 No task is in progress. You MUST use `tilldone toggle` to mark a task as inprogress before doing any work.",
};
}
return { block: false };
});
// ── Auto-nudge on agent_end ────────────────────────────────────────
pi.on("agent_end", async (_event, _ctx) => {
const incomplete = tasks.filter((t) => t.status !== "done");
if (incomplete.length === 0 || nudgedThisCycle) return;
nudgedThisCycle = true;
const taskList = incomplete
.map((t) => ` ${STATUS_ICON[t.status]} #${t.id} [${STATUS_LABEL[t.status]}]: ${t.text}`)
.join("\n");
pi.sendMessage(
{
customType: "tilldone-nudge",
content: `⚠️ You still have ${incomplete.length} incomplete task(s):\n\n${taskList}\n\nEither continue working on them or mark them done with \`tilldone toggle\`. Don't stop until it's done!`,
display: true,
},
{ triggerTurn: true },
);
});
pi.on("input", async () => {
nudgedThisCycle = false;
return { action: "continue" as const };
});
// ── Register tilldone tool ─────────────────────────────────────────
pi.registerTool({
name: "tilldone",
label: "TillDone",
description:
"Manage your task list. You MUST add tasks before using any other tools. " +
"Actions: new-list (text=title, description), add (text or texts[] for batch), toggle (id) — cycles idle→inprogress→done, remove (id), update (id + text), list, clear. " +
"Always toggle a task to inprogress before starting work on it, and to done when finished. " +
"Use new-list to start a themed list with a title and description. " +
"IMPORTANT: If the user's new request does not fit the current list's theme, use clear to wipe the slate and new-list to start fresh.",
parameters: TillDoneParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
switch (params.action) {
case "new-list": {
if (!params.text) {
return {
content: [{ type: "text" as const, text: "Error: text (title) required for new-list" }],
details: makeDetails("new-list", "text required"),
};
}
// If a list already exists, confirm before replacing
if (tasks.length > 0 || listTitle) {
const confirmed = await ctx.ui.confirm(
"Start a new list?",
`This will replace${listTitle ? ` "${listTitle}"` : " the current list"} (${tasks.length} task(s)). Continue?`,
{ timeout: 30000 },
);
if (!confirmed) {
return {
content: [{ type: "text" as const, text: "New list cancelled by user." }],
details: makeDetails("new-list", "cancelled"),
};
}
}
tasks = [];
nextId = 1;
listTitle = params.text;
listDescription = params.description || undefined;
const result = {
content: [{
type: "text" as const,
text: `New list: "${listTitle}"${listDescription ? `${listDescription}` : ""}`,
}],
details: makeDetails("new-list"),
};
refreshUI(ctx);
return result;
}
case "list": {
const header = listTitle ? `${listTitle}:` : "";
const result = {
content: [{
type: "text" as const,
text: tasks.length
? (header ? header + "\n" : "") +
tasks.map((t) => `[${STATUS_ICON[t.status]}] #${t.id} (${t.status}): ${t.text}`).join("\n")
: "No tasks defined yet.",
}],
details: makeDetails("list"),
};
refreshUI(ctx);
return result;
}
case "add": {
const items = params.texts?.length ? params.texts : params.text ? [params.text] : [];
if (items.length === 0) {
return {
content: [{ type: "text" as const, text: "Error: text or texts required for add" }],
details: makeDetails("add", "text required"),
};
}
const added: Task[] = [];
for (const item of items) {
const t: Task = { id: nextId++, text: item, status: "idle" };
tasks.push(t);
added.push(t);
}
const msg = added.length === 1
? `Added task #${added[0].id}: ${added[0].text}`
: `Added ${added.length} tasks: ${added.map((t) => `#${t.id}`).join(", ")}`;
const result = {
content: [{ type: "text" as const, text: msg }],
details: makeDetails("add"),
};
refreshUI(ctx);
return result;
}
case "toggle": {
if (params.id === undefined) {
return {
content: [{ type: "text" as const, text: "Error: id required for toggle" }],
details: makeDetails("toggle", "id required"),
};
}
const task = tasks.find((t) => t.id === params.id);
if (!task) {
return {
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
details: makeDetails("toggle", `#${params.id} not found`),
};
}
const prev = task.status;
task.status = NEXT_STATUS[task.status];
// Enforce single inprogress — demote any other active task
const demoted: Task[] = [];
if (task.status === "inprogress") {
for (const t of tasks) {
if (t.id !== task.id && t.status === "inprogress") {
t.status = "idle";
demoted.push(t);
}
}
}
let msg = `Task #${task.id}: ${prev}${task.status}`;
if (demoted.length > 0) {
msg += `\n(Auto-paused ${demoted.map((t) => `#${t.id}`).join(", ")} → idle. Only one task can be in progress at a time.)`;
}
const result = {
content: [{
type: "text" as const,
text: msg,
}],
details: makeDetails("toggle"),
};
refreshUI(ctx);
return result;
}
case "remove": {
if (params.id === undefined) {
return {
content: [{ type: "text" as const, text: "Error: id required for remove" }],
details: makeDetails("remove", "id required"),
};
}
const idx = tasks.findIndex((t) => t.id === params.id);
if (idx === -1) {
return {
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
details: makeDetails("remove", `#${params.id} not found`),
};
}
const removed = tasks.splice(idx, 1)[0];
const result = {
content: [{ type: "text" as const, text: `Removed task #${removed.id}: ${removed.text}` }],
details: makeDetails("remove"),
};
refreshUI(ctx);
return result;
}
case "update": {
if (params.id === undefined) {
return {
content: [{ type: "text" as const, text: "Error: id required for update" }],
details: makeDetails("update", "id required"),
};
}
if (!params.text) {
return {
content: [{ type: "text" as const, text: "Error: text required for update" }],
details: makeDetails("update", "text required"),
};
}
const toUpdate = tasks.find((t) => t.id === params.id);
if (!toUpdate) {
return {
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
details: makeDetails("update", `#${params.id} not found`),
};
}
const oldText = toUpdate.text;
toUpdate.text = params.text;
const result = {
content: [{ type: "text" as const, text: `Updated #${toUpdate.id}: "${oldText}" → "${toUpdate.text}"` }],
details: makeDetails("update"),
};
refreshUI(ctx);
return result;
}
case "clear": {
if (tasks.length > 0) {
const confirmed = await ctx.ui.confirm(
"Clear TillDone list?",
`This will remove all ${tasks.length} task(s)${listTitle ? ` from "${listTitle}"` : ""}. Continue?`,
{ timeout: 30000 },
);
if (!confirmed) {
return {
content: [{ type: "text" as const, text: "Clear cancelled by user." }],
details: makeDetails("clear", "cancelled"),
};
}
}
const count = tasks.length;
tasks = [];
nextId = 1;
listTitle = undefined;
listDescription = undefined;
const result = {
content: [{ type: "text" as const, text: `Cleared ${count} task(s)` }],
details: makeDetails("clear"),
};
refreshUI(ctx);
return result;
}
default:
return {
content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }],
details: makeDetails("list", `unknown action: ${params.action}`),
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("tilldone ")) + theme.fg("muted", args.action);
if (args.texts?.length) text += ` ${theme.fg("dim", `${args.texts.length} tasks`)}`;
else if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
if (args.description) text += ` ${theme.fg("dim", `${args.description}`)}`;
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as TillDoneDetails | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.error) {
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
}
const taskList = details.tasks;
switch (details.action) {
case "new-list": {
let msg = theme.fg("success", "✓ New list ") + theme.fg("accent", `"${details.listTitle}"`);
if (details.listDescription) {
msg += theme.fg("dim", `${details.listDescription}`);
}
return new Text(msg, 0, 0);
}
case "list": {
if (taskList.length === 0) return new Text(theme.fg("dim", "No tasks"), 0, 0);
let listText = "";
if (details.listTitle) {
listText += theme.fg("accent", details.listTitle) + theme.fg("dim", " ");
}
listText += theme.fg("muted", `${taskList.length} task(s):`);
const display = expanded ? taskList : taskList.slice(0, 5);
for (const t of display) {
const icon = t.status === "done"
? theme.fg("success", STATUS_ICON.done)
: t.status === "inprogress"
? theme.fg("accent", STATUS_ICON.inprogress)
: theme.fg("dim", STATUS_ICON.idle);
const itemText = t.status === "done"
? theme.fg("dim", t.text)
: t.status === "inprogress"
? theme.fg("success", t.text)
: theme.fg("muted", t.text);
listText += `\n${icon} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
}
if (!expanded && taskList.length > 5) {
listText += `\n${theme.fg("dim", `... ${taskList.length - 5} more`)}`;
}
return new Text(listText, 0, 0);
}
case "add": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
}
case "toggle": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("accent", "⟳ ") + theme.fg("muted", msg), 0, 0);
}
case "remove": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("warning", "✕ ") + theme.fg("muted", msg), 0, 0);
}
case "update": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
}
case "clear":
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all tasks"), 0, 0);
default:
return new Text(theme.fg("dim", "done"), 0, 0);
}
},
});
// ── /tilldone command ──────────────────────────────────────────────
pi.registerCommand("tilldone", {
description: "Show all TillDone tasks on the current branch",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("/tilldone requires interactive mode", "error");
return;
}
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
return new TillDoneListComponent(tasks, listTitle, listDescription, theme, () => done());
});
},
});
}

View File

@@ -0,0 +1,68 @@
/**
* Tool Counter Widget — Tool call counts in a widget above the editor
*
* Shows a persistent, live-updating widget with per-tool background colors.
* Format: Tools (N): [Bash 3] [Read 7] [Write 2]
*
* Usage: pi -e extensions/tool-counter-widget.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Box, Text } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./themeMap.ts";
const palette = [
[12, 40, 80], // deep navy
[50, 20, 70], // dark purple
[10, 55, 45], // dark teal
[70, 30, 10], // dark rust
[55, 15, 40], // dark plum
[15, 50, 65], // dark ocean
[45, 45, 15], // dark olive
[65, 18, 25], // dark wine
];
function bg(rgb: number[], s: string): string {
return `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[49m`;
}
export default function (pi: ExtensionAPI) {
const counts: Record<string, number> = {};
const toolColors: Record<string, number[]> = {};
let total = 0;
let colorIdx = 0;
pi.on("tool_execution_end", async (event) => {
if (!(event.toolName in toolColors)) {
toolColors[event.toolName] = palette[colorIdx % palette.length];
colorIdx++;
}
counts[event.toolName] = (counts[event.toolName] || 0) + 1;
total++;
});
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
ctx.ui.setWidget("tool-counter", (_tui, theme) => {
const text = new Text("", 1, 1);
return {
render(width: number): string[] {
const entries = Object.entries(counts);
const parts = entries.map(([name, count]) => {
const rgb = toolColors[name];
return bg(rgb, `\x1b[38;2;220;220;220m ${name} ${count} \x1b[39m`);
});
text.setText(
theme.fg("accent", `Tools (${total}):`) +
(entries.length > 0 ? " " + parts.join(" ") : "")
);
return text.render(width);
},
invalidate() {
text.invalidate();
},
};
});
});
}

102
extensions/tool-counter.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Tool Counter — Rich two-line custom footer
*
* Line 1: model + context meter on left, tokens in/out + cost on right
* Line 2: cwd (branch) on left, tool call tally on right
*
* Demonstrates: setFooter, footerData.getGitBranch(), onBranchChange(),
* session branch traversal for token/cost accumulation.
*
* Usage: pi -e extensions/tool-counter.ts
*/
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { basename } from "node:path";
import { applyExtensionDefaults } from "./themeMap.ts";
export default function (pi: ExtensionAPI) {
const counts: Record<string, number> = {};
pi.on("tool_execution_end", async (event) => {
counts[event.toolName] = (counts[event.toolName] || 0) + 1;
});
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
ctx.ui.setFooter((tui, theme, footerData) => {
const unsub = footerData.onBranchChange(() => tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
// --- Line 1: cwd + branch (left), tokens + cost (right) ---
let tokIn = 0;
let tokOut = 0;
let cost = 0;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "assistant") {
const m = entry.message as AssistantMessage;
tokIn += m.usage.input;
tokOut += m.usage.output;
cost += m.usage.cost.total;
}
}
const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
const dir = basename(ctx.cwd);
const branch = footerData.getGitBranch();
// --- Line 1: model + context meter (left), tokens + cost (right) ---
const usage = ctx.getContextUsage();
const pct = usage ? usage.percent : 0;
const filled = Math.round(pct / 10) || 1;
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const model = ctx.model?.id || "no-model";
const l1Left =
theme.fg("dim", ` ${model} `) +
theme.fg("warning", "[") +
theme.fg("success", "#".repeat(filled)) +
theme.fg("dim", "-".repeat(10 - filled)) +
theme.fg("warning", "]") +
theme.fg("dim", " ") +
theme.fg("accent", `${Math.round(pct)}%`);
const l1Right =
theme.fg("success", `${fmt(tokIn)}`) +
theme.fg("dim", " in ") +
theme.fg("accent", `${fmt(tokOut)}`) +
theme.fg("dim", " out ") +
theme.fg("warning", `$${cost.toFixed(4)}`) +
theme.fg("dim", " ");
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right)));
const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, "");
// --- Line 2: cwd + branch (left), tool tally (right) ---
const l2Left =
theme.fg("dim", ` ${dir}`) +
(branch
? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")")
: "");
const entries = Object.entries(counts);
const l2Right = entries.length === 0
? theme.fg("dim", "waiting for tools ")
: entries.map(
([name, count]) =>
theme.fg("accent", name) + theme.fg("dim", " ") + theme.fg("success", `${count}`)
).join(theme.fg("warning", " | ")) + theme.fg("dim", " ");
const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right)));
const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, "");
return [line1, line2];
},
};
});
});
}