Files
pi-skill/extensions/mode-cycler.ts
2026-05-25 16:41:08 +07:00

256 lines
8.4 KiB
TypeScript

// ABOUTME: Cycles operational modes (NORMAL/PLAN/SPEC/PIPELINE/TEAM/CHAIN) via Shift+Tab.
// ABOUTME: Gates which extension's before_agent_start fires and injects PLAN/SPEC prompts.
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { Text } from "@mariozechner/pi-tui";
import { outputLine } from "./lib/output-box.ts";
import { applyExtensionDefaults } from "./lib/themeMap.ts";
import { MODES, nextMode, modeLabel, modeBgAnsi, modeTextAnsi, type Mode } from "./lib/mode-cycler-logic.ts";
import { PLAN_PROMPT, SPEC_PROMPT, buildNormalPrompt } from "./lib/mode-prompts.ts";
import { writeFileSync } from "fs";
import { showBanner, isBannerVisible } from "./agent-banner.ts";
const MODE_FILE = "/tmp/pi-current-mode.txt";
export default function (pi: ExtensionAPI) {
let currentMode: Mode = "NORMAL";
function updateWidgets(mode: Mode, ctx: ExtensionContext) {
if (!ctx.hasUI) return;
if (mode === "NORMAL") {
ctx.ui.setWidget("mode-block", undefined);
// Re-set agent-banner after clearing mode-block to ensure correct rendering order
// Only re-set if banner was previously visible (not hidden by user input)
if (isBannerVisible()) {
showBanner(ctx);
}
return;
}
// Mode block — full-width colored banner with mode name
// Uses theme accent color (same as model name in footer)
ctx.ui.setWidget(
"mode-block",
(_tui, _theme) => ({
invalidate() {},
render(width: number): string[] {
const bg = modeBgAnsi(mode);
const text = modeTextAnsi(mode);
const reset = "\x1b[0m";
const label = ` ${mode} `;
const pad = " ".repeat(Math.max(0, width - label.length));
return [bg + text + label + pad + reset];
},
}),
{ placement: "aboveEditor" },
);
// Re-set agent-banner after setting mode-block to ensure it renders above the bar
// This maintains the visual hierarchy: agent-banner (logo) → mode-block (bar) → editor
// Only re-set if banner was previously visible (not hidden by user input)
if (isBannerVisible()) {
showBanner(ctx);
}
}
// Expose refresh function so other extensions (e.g. agent-team) can re-pin
// the mode-block as the last aboveEditor widget (closest to the editor input).
function refreshModeBlock(ctx: ExtensionContext) {
updateWidgets(currentMode, ctx);
}
function setMode(mode: Mode, ctx: ExtensionContext) {
currentMode = mode;
(globalThis as any).__piCurrentMode = mode;
// Write to temp file for statusline
try { writeFileSync(MODE_FILE, mode, "utf-8"); } catch {}
if (ctx.hasUI) {
ctx.ui.setStatus("mode", modeLabel(mode));
}
// Publish refresh callback so other aboveEditor widgets can re-pin the mode bar
(globalThis as any).__piRefreshModeBlock = () => refreshModeBlock(ctx);
updateWidgets(mode, ctx);
}
// ── Shift+Tab: cycle forward ──────────────────
pi.registerShortcut("shift+tab", {
description: "Cycle operational mode",
handler: async (ctx) => {
setMode(nextMode(currentMode), ctx);
},
});
// ── /thinking command ─────────────────────────
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
pi.registerCommand("thinking", {
description: "Set thinking level: /thinking or /thinking <LEVEL>",
handler: async (args, ctx) => {
if (!ctx.hasUI) return;
const arg = args.trim().toLowerCase();
if (arg && THINKING_LEVELS.includes(arg)) {
pi.setThinkingLevel(arg);
ctx.ui.notify(`Thinking: ${arg}`);
return;
}
if (arg) {
ctx.ui.notify(`Unknown level: ${arg}. Valid: ${THINKING_LEVELS.join(", ")}`, "error");
return;
}
// Picker
const current = pi.getThinkingLevel();
const items = THINKING_LEVELS.map(l => {
const active = l === current ? " (active)" : "";
return `${l}${active}`;
});
const selected = await ctx.ui.select("Select Thinking Level", items);
if (!selected) return;
const level = selected.split(/\s/)[0];
pi.setThinkingLevel(level);
ctx.ui.notify(`Thinking: ${level}`);
},
autocomplete: (partial) => {
return THINKING_LEVELS.filter(l => l.startsWith(partial.toLowerCase()));
},
});
// ── /mode command ─────────────────────────────
pi.registerCommand("mode", {
description: "Set mode: /mode or /mode <MODE>",
handler: async (args, ctx) => {
if (!ctx.hasUI) return;
const arg = args.trim().toUpperCase();
if (arg && MODES.includes(arg as Mode)) {
setMode(arg as Mode, ctx);
return;
}
if (arg) {
ctx.ui.notify(`Unknown mode: ${arg}. Valid: ${MODES.join(", ")}`, "error");
return;
}
// Picker
const items = MODES.map(m => {
const active = m === currentMode ? " (active)" : "";
return `${m}${active}`;
});
const selected = await ctx.ui.select("Select Mode", items);
if (!selected) return;
const name = selected.split(/\s/)[0] as Mode;
setMode(name, ctx);
},
});
// ── set_mode tool (autonomous mode switching) ──
pi.registerTool({
name: "set_mode",
label: "Set Mode",
description: "Switch the operational mode. Call this from NORMAL mode to activate PLAN, SPEC, TEAM, CHAIN, or PIPELINE based on task classification.",
parameters: Type.Object({
mode: Type.String({ description: "Target mode: NORMAL, PLAN, SPEC, PIPELINE, TEAM, or CHAIN" }),
reason: Type.Optional(Type.String({ description: "Why this mode was chosen" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { mode: target, reason } = params as { mode: string; reason?: string };
const upper = target.toUpperCase();
if (!MODES.includes(upper as Mode)) {
return {
content: [{ type: "text", text: `Unknown mode: ${target}. Valid: ${MODES.join(", ")}` }],
details: { error: true },
};
}
setMode(upper as Mode, ctx);
const msg = reason
? `Mode set to ${upper}. Reason: ${reason}`
: `Mode set to ${upper}.`;
return {
content: [{ type: "text", text: msg }],
details: { mode: upper, reason },
};
},
renderCall(args, theme) {
const target = (args as any).mode || "?";
const reason = (args as any).reason || "";
const preview = reason.length > 50 ? reason.slice(0, 47) + "..." : reason;
const text =
theme.fg("toolTitle", theme.bold("set_mode ")) +
theme.fg("accent", target.toUpperCase()) +
(preview ? theme.fg("dim", " — ") + theme.fg("muted", preview) : "");
return new Text(outputLine(theme, "accent", text), 0, 0);
},
renderResult(result, _options, theme) {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(outputLine(theme, "success", msg), 0, 0);
},
});
// ── System prompt injection per mode ─────────
pi.on("before_agent_start", async (_event, _ctx) => {
if (currentMode === "NORMAL") {
const g = globalThis as any;
const scoutId = typeof g.__piScoutId === "number" ? g.__piScoutId : null;
return { systemPrompt: buildNormalPrompt({
commanderAvailable: !!g.__piCommanderAvailable,
activeChain: g.__piActiveChain || null,
activePipeline: g.__piActivePipeline || null,
scoutId,
})};
}
if (currentMode === "PLAN") return { systemPrompt: PLAN_PROMPT };
if (currentMode === "SPEC") return { systemPrompt: SPEC_PROMPT };
return {};
});
// ── Session init ──────────────────────────────
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
currentMode = "NORMAL";
(globalThis as any).__piCurrentMode = "NORMAL";
(globalThis as any).__piRefreshModeBlock = () => refreshModeBlock(ctx);
try { writeFileSync(MODE_FILE, "NORMAL", "utf-8"); } catch {}
if (ctx.hasUI) {
ctx.ui.setStatus("mode", "");
}
updateWidgets("NORMAL", ctx);
});
// ── Session switch (/new) ──────────────────────
pi.on("session_switch", async (_event, ctx) => {
// Re-apply current mode widgets after banner is shown to ensure correct rendering order
// The banner is shown in agent-banner.ts's session_switch handler, so we need to
// re-set widgets here to ensure mode-block (if any) renders before banner is re-set
// Use process.nextTick to ensure banner's session_switch handler runs first
process.nextTick(() => {
updateWidgets(currentMode, ctx);
});
});
}