Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
708
extensions/spec-viewer.ts
Normal file
708
extensions/spec-viewer.ts
Normal file
@@ -0,0 +1,708 @@
|
||||
// ABOUTME: Spec Viewer — opens a multi-page browser GUI for reviewing, commenting, and approving specifications.
|
||||
// ABOUTME: Wizard-style navigation between spec docs, inline comment threads, visual asset gallery, markdown editing.
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, basename, dirname, extname, resolve, relative } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { outputLine } from "./lib/output-box.ts";
|
||||
import { applyExtensionDefaults } from "./lib/themeMap.ts";
|
||||
import { generateSpecViewerHTML, type SpecDocument } from "./lib/spec-viewer-html.ts";
|
||||
import { createSpecStandaloneExport, loadVisualAsExportAsset, saveStandaloneExport, type SpecExportDocument } from "./lib/viewer-standalone-export.ts";
|
||||
import { upsertPersistedReport } from "./lib/report-index.ts";
|
||||
import { registerActiveViewer, clearActiveViewer, notifyViewerOpen } from "./lib/viewer-session.ts";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
interface SpecComment {
|
||||
id: string;
|
||||
document: string;
|
||||
sectionId: string;
|
||||
sectionText: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface SpecViewerResult {
|
||||
action: "approved" | "changes_requested" | "declined";
|
||||
comments: SpecComment[];
|
||||
markdownChanges: Record<string, string>;
|
||||
modified: boolean;
|
||||
}
|
||||
|
||||
// ── MIME Types ────────────────────────────────────────────────────────
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".md": "text/markdown",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
};
|
||||
|
||||
// ── Folder Discovery ─────────────────────────────────────────────────
|
||||
|
||||
function discoverSpecDocuments(folderPath: string): SpecDocument[] {
|
||||
const docs: SpecDocument[] = [];
|
||||
|
||||
// 1. spec.md — main spec document
|
||||
const specPath = join(folderPath, "spec.md");
|
||||
if (existsSync(specPath)) {
|
||||
docs.push({
|
||||
key: "spec",
|
||||
label: "Spec",
|
||||
markdown: readFileSync(specPath, "utf-8"),
|
||||
filePath: "spec.md",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. planning/requirements.md
|
||||
const reqPath = join(folderPath, "planning", "requirements.md");
|
||||
if (existsSync(reqPath)) {
|
||||
docs.push({
|
||||
key: "requirements",
|
||||
label: "Requirements",
|
||||
markdown: readFileSync(reqPath, "utf-8"),
|
||||
filePath: "planning/requirements.md",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Tasks — planning/tasks.md or any tasks*.md in folder
|
||||
const tasksPath = join(folderPath, "planning", "tasks.md");
|
||||
if (existsSync(tasksPath)) {
|
||||
docs.push({
|
||||
key: "tasks",
|
||||
label: "Tasks",
|
||||
markdown: readFileSync(tasksPath, "utf-8"),
|
||||
filePath: "planning/tasks.md",
|
||||
});
|
||||
} else {
|
||||
// Check root for tasks*.md
|
||||
try {
|
||||
const rootFiles = readdirSync(folderPath);
|
||||
const taskFile = rootFiles.find((f) => f.startsWith("tasks") && f.endsWith(".md"));
|
||||
if (taskFile) {
|
||||
docs.push({
|
||||
key: "tasks",
|
||||
label: "Tasks",
|
||||
markdown: readFileSync(join(folderPath, taskFile), "utf-8"),
|
||||
filePath: taskFile,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 4. Visuals — planning/visuals/ folder
|
||||
const visualsDir = join(folderPath, "planning", "visuals");
|
||||
if (existsSync(visualsDir)) {
|
||||
try {
|
||||
const visualFiles = readdirSync(visualsDir)
|
||||
.filter((f) => {
|
||||
const ext = extname(f).toLowerCase();
|
||||
return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".html", ".htm"].includes(ext);
|
||||
})
|
||||
.map((f) => join("planning", "visuals", f));
|
||||
|
||||
if (visualFiles.length > 0) {
|
||||
docs.push({
|
||||
key: "visuals",
|
||||
label: "Visuals",
|
||||
markdown: "",
|
||||
filePath: "planning/visuals/",
|
||||
isVisuals: true,
|
||||
visualFiles,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 5. Other planning docs (excluding already-added ones)
|
||||
const planningDir = join(folderPath, "planning");
|
||||
if (existsSync(planningDir)) {
|
||||
try {
|
||||
const knownFiles = new Set(["requirements.md", "tasks.md", "initialization.md", "questions.md"]);
|
||||
const planningFiles = readdirSync(planningDir)
|
||||
.filter((f) => f.endsWith(".md") && !knownFiles.has(f))
|
||||
.sort();
|
||||
|
||||
for (const file of planningFiles) {
|
||||
const key = "other-" + file.replace(".md", "");
|
||||
docs.push({
|
||||
key,
|
||||
label: basename(file, ".md").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
markdown: readFileSync(join(planningDir, file), "utf-8"),
|
||||
filePath: join("planning", file),
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
// ── HTTP Server ──────────────────────────────────────────────────────
|
||||
|
||||
function buildStandaloneSpecDocuments(folderPath: string, documents: SpecDocument[], markdownChanges?: Record<string, string>): SpecExportDocument[] {
|
||||
return documents.map((doc) => {
|
||||
if (doc.isVisuals) {
|
||||
return {
|
||||
label: doc.label,
|
||||
filePath: doc.filePath,
|
||||
isVisuals: true,
|
||||
visuals: (doc.visualFiles || []).map((file) => loadVisualAsExportAsset(folderPath, file)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: doc.label,
|
||||
filePath: doc.filePath,
|
||||
markdown: markdownChanges?.[doc.filePath] ?? doc.markdown,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function startSpecViewerServer(
|
||||
folderPath: string,
|
||||
documents: SpecDocument[],
|
||||
title: string,
|
||||
existingComments: SpecComment[],
|
||||
): Promise<{ port: number; server: Server; waitForResult: () => Promise<SpecViewerResult> }> {
|
||||
return new Promise((resolveSetup) => {
|
||||
let resolveResult: (result: SpecViewerResult) => void;
|
||||
const resultPromise = new Promise<SpecViewerResult>((res) => {
|
||||
resolveResult = res;
|
||||
});
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || "/", `http://localhost`);
|
||||
|
||||
// Serve the main HTML page
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
const port = (server.address() as any)?.port || 0;
|
||||
const html = generateSpecViewerHTML({
|
||||
documents,
|
||||
title,
|
||||
port,
|
||||
existingComments: JSON.stringify(existingComments),
|
||||
});
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve the logo
|
||||
if (req.method === "GET" && url.pathname === "/logo.png") {
|
||||
try {
|
||||
const logoPath = join(dirname(fileURLToPath(import.meta.url)), "assets", "agent-logo.png");
|
||||
const logoData = readFileSync(logoPath);
|
||||
res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" });
|
||||
res.end(logoData);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve files from spec folder (path-restricted)
|
||||
if (req.method === "GET" && url.pathname === "/file") {
|
||||
const relPath = url.searchParams.get("path");
|
||||
if (!relPath) {
|
||||
res.writeHead(400);
|
||||
res.end("Missing path parameter");
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: prevent directory traversal
|
||||
const absPath = resolve(folderPath, relPath);
|
||||
const normalizedFolder = resolve(folderPath);
|
||||
if (!absPath.startsWith(normalizedFolder)) {
|
||||
res.writeHead(403);
|
||||
res.end("Access denied");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = readFileSync(absPath);
|
||||
const ext = extname(absPath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "public, max-age=300" });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end("File not found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle result submission
|
||||
if (req.method === "POST" && url.pathname === "/result") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
resolveResult!({
|
||||
action: data.action || "declined",
|
||||
comments: data.comments || [],
|
||||
markdownChanges: data.markdownChanges || {},
|
||||
modified: data.modified || false,
|
||||
});
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save comments
|
||||
if (req.method === "POST" && url.pathname === "/save") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const commentsPath = join(folderPath, "spec-comments.json");
|
||||
writeFileSync(commentsPath, JSON.stringify({ comments: data.comments || [] }, null, 2), "utf-8");
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} catch (err: any) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/export-standalone") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body || "{}");
|
||||
const exportDocs = buildStandaloneSpecDocuments(folderPath, documents, data.markdownChanges || {});
|
||||
const html = createSpecStandaloneExport({ title, documents: exportDocs });
|
||||
const saved = saveStandaloneExport({ filePrefix: "spec-readonly", html });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, message: `Standalone export saved to ~/Desktop/${saved.fileName}` }));
|
||||
} catch (err: any) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as any;
|
||||
resolveSetup({
|
||||
port: addr.port,
|
||||
server,
|
||||
waitForResult: () => resultPromise,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Browser Helper ───────────────────────────────────────────────────
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
try {
|
||||
execSync(`open "${url}"`, { stdio: "ignore" });
|
||||
} catch {
|
||||
try {
|
||||
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
|
||||
} catch {
|
||||
try {
|
||||
execSync(`start "${url}"`, { stdio: "ignore" });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comment Formatting ───────────────────────────────────────────────
|
||||
|
||||
function formatCommentsForAgent(comments: SpecComment[]): string {
|
||||
if (comments.length === 0) return "(no comments)";
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const c of comments) {
|
||||
const docLabel = c.document.replace(/-/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||
lines.push(`[${docLabel}] ${c.sectionText}`);
|
||||
lines.push(` → ${c.text}`);
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
// ── Tool Parameters ──────────────────────────────────────────────────
|
||||
|
||||
const ShowSpecParams = Type.Object({
|
||||
folder_path: Type.String({ description: "Path to the spec folder (e.g. context-os/specs/2025-06-25-feature/)" }),
|
||||
title: Type.Optional(Type.String({ description: "Title to display in the viewer header" })),
|
||||
});
|
||||
|
||||
// ── Extension ────────────────────────────────────────────────────────
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let piRef = pi;
|
||||
let activeServer: Server | null = null;
|
||||
let activeSession: { kind: "spec"; title: string; url: string; server: Server; onClose: () => void } | null = null;
|
||||
|
||||
function cleanupServer() {
|
||||
const server = activeServer;
|
||||
activeServer = null;
|
||||
if (server) {
|
||||
try { server.close(); } catch {}
|
||||
}
|
||||
if (activeSession) {
|
||||
clearActiveViewer(activeSession);
|
||||
activeSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core viewer logic ────────────────────────────────────────────
|
||||
|
||||
async function runSpecViewer(
|
||||
ctx: ExtensionContext,
|
||||
folderPath: string,
|
||||
title: string,
|
||||
): Promise<SpecViewerResult> {
|
||||
cleanupServer();
|
||||
|
||||
// Discover documents
|
||||
const documents = discoverSpecDocuments(folderPath);
|
||||
if (documents.length === 0) {
|
||||
throw new Error(`No spec documents found in ${folderPath}`);
|
||||
}
|
||||
|
||||
// Load existing comments
|
||||
let existingComments: SpecComment[] = [];
|
||||
const commentsPath = join(folderPath, "spec-comments.json");
|
||||
if (existsSync(commentsPath)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(commentsPath, "utf-8"));
|
||||
existingComments = data.comments || [];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Start server
|
||||
const { port, server, waitForResult } = await startSpecViewerServer(
|
||||
folderPath,
|
||||
documents,
|
||||
title,
|
||||
existingComments,
|
||||
);
|
||||
activeServer = server;
|
||||
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
activeSession = {
|
||||
kind: "spec",
|
||||
title: "Spec viewer",
|
||||
url,
|
||||
server,
|
||||
onClose: () => {
|
||||
activeServer = null;
|
||||
activeSession = null;
|
||||
},
|
||||
};
|
||||
registerActiveViewer(activeSession);
|
||||
openBrowser(url);
|
||||
notifyViewerOpen(ctx, activeSession);
|
||||
|
||||
try {
|
||||
const result = await waitForResult();
|
||||
|
||||
// Save any markdown changes back to files
|
||||
if (result.modified && result.markdownChanges) {
|
||||
for (const [relPath, content] of Object.entries(result.markdownChanges)) {
|
||||
try {
|
||||
const absPath = resolve(folderPath, relPath);
|
||||
// Security check
|
||||
if (absPath.startsWith(resolve(folderPath))) {
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Save final comments
|
||||
if (result.comments && result.comments.length > 0) {
|
||||
try {
|
||||
writeFileSync(commentsPath, JSON.stringify({ comments: result.comments }, null, 2), "utf-8");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const editedDocCount = result.markdownChanges ? Object.keys(result.markdownChanges).length : 0;
|
||||
upsertPersistedReport({
|
||||
category: "spec",
|
||||
title,
|
||||
summary: `${documents.length} document(s) reviewed${result.comments.length ? `, ${result.comments.length} comment(s)` : ""}`,
|
||||
sourcePath: folderPath,
|
||||
viewerPath: folderPath,
|
||||
viewerLabel: title,
|
||||
tags: ["spec", "review"],
|
||||
metadata: {
|
||||
action: result.action,
|
||||
modified: result.modified,
|
||||
commentCount: result.comments.length,
|
||||
editedDocCount,
|
||||
documentCount: documents.length,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
cleanupServer();
|
||||
}
|
||||
}
|
||||
|
||||
// ── show_spec tool ───────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "show_spec",
|
||||
label: "Show Spec",
|
||||
description:
|
||||
"Open a multi-page spec viewer in the browser. Displays all spec documents " +
|
||||
"(spec.md, requirements, tasks, visuals) as wizard steps with inline comment " +
|
||||
"threads and markdown editing. Takes a spec folder path and auto-discovers documents.\n\n" +
|
||||
"The user can:\n" +
|
||||
"- Navigate between documents using wizard steps\n" +
|
||||
"- Add inline comments on any section (Google Docs-style)\n" +
|
||||
"- Edit markdown in raw mode\n" +
|
||||
"- View visual assets in a gallery\n" +
|
||||
"- Approve the spec or request changes with comment feedback",
|
||||
parameters: ShowSpecParams,
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const { folder_path, title: titleParam } = params as {
|
||||
folder_path: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
// Resolve folder path
|
||||
const folderPath = resolve(folder_path);
|
||||
if (!existsSync(folderPath) || !statSync(folderPath).isDirectory()) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: folder not found: ${folder_path}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const displayTitle = titleParam || basename(folderPath);
|
||||
|
||||
try {
|
||||
const result = await runSpecViewer(ctx, folderPath, displayTitle);
|
||||
|
||||
// Handle approved
|
||||
if (result.action === "approved") {
|
||||
const modifiedNote = result.modified
|
||||
? " (spec was edited by user — use the updated version)"
|
||||
: "";
|
||||
|
||||
piRef.sendMessage(
|
||||
{
|
||||
customType: "spec-approved",
|
||||
content: `Spec approved! Proceed with implementation.${modifiedNote}`,
|
||||
display: true,
|
||||
},
|
||||
{ deliverAs: "followUp" as any, triggerTurn: true },
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `Spec approved by user.${modifiedNote} Modified files have been saved.`,
|
||||
}],
|
||||
details: {
|
||||
action: "approved" as const,
|
||||
modified: result.modified,
|
||||
folderPath: folder_path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle changes requested
|
||||
if (result.action === "changes_requested") {
|
||||
const commentSummary = formatCommentsForAgent(result.comments);
|
||||
const modifiedNote = result.modified
|
||||
? "\n\nNote: Some documents were also edited inline — check the updated files."
|
||||
: "";
|
||||
|
||||
piRef.sendMessage(
|
||||
{
|
||||
customType: "spec-changes-requested",
|
||||
content: `Changes requested on the spec. Here are the comments:\n\n${commentSummary}${modifiedNote}`,
|
||||
display: true,
|
||||
},
|
||||
{ deliverAs: "followUp" as any, triggerTurn: true },
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `User requested changes to the spec. Comments:\n\n${commentSummary}${modifiedNote}`,
|
||||
}],
|
||||
details: {
|
||||
action: "changes_requested" as const,
|
||||
comments: result.comments,
|
||||
modified: result.modified,
|
||||
folderPath: folder_path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Declined / closed
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: "User closed the spec viewer without approving. Ask if they want changes or have feedback.",
|
||||
}],
|
||||
details: {
|
||||
action: "declined" as const,
|
||||
folderPath: folder_path,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Spec viewer error: ${err.message}` }],
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
const folderPath = (args as any).folder_path || "?";
|
||||
const titleArg = (args as any).title || "";
|
||||
const text =
|
||||
theme.fg("toolTitle", theme.bold("show_spec ")) +
|
||||
theme.fg("accent", folderPath) +
|
||||
(titleArg ? theme.fg("dim", ` — ${titleArg}`) : "");
|
||||
return new Text(outputLine(theme, "accent", text), 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 (details.action === "approved") {
|
||||
const modNote = details.modified ? " (edited)" : "";
|
||||
return new Text(
|
||||
outputLine(theme, "success", `Spec approved${modNote}`),
|
||||
0, 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (details.action === "changes_requested") {
|
||||
const count = details.comments?.length || 0;
|
||||
return new Text(
|
||||
outputLine(theme, "warning", `Changes requested (${count} comment${count !== 1 ? "s" : ""})`),
|
||||
0, 0,
|
||||
);
|
||||
}
|
||||
|
||||
return new Text(
|
||||
outputLine(theme, "warning", "Spec viewer closed without action"),
|
||||
0, 0,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ── /spec command ────────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("spec", {
|
||||
description: "Open the spec viewer for a spec folder (e.g. /spec context-os/specs/2025-06-25-feature/)",
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("/spec requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderPath = args.trim();
|
||||
if (!folderPath) {
|
||||
ctx.ui.notify("Usage: /spec <folder-path>", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolve(folderPath);
|
||||
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
||||
ctx.ui.notify(`Not a folder: ${folderPath}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const displayTitle = basename(resolved);
|
||||
|
||||
try {
|
||||
const result = await runSpecViewer(ctx, resolved, displayTitle);
|
||||
|
||||
if (result.action === "approved") {
|
||||
piRef.sendMessage(
|
||||
{
|
||||
customType: "spec-approved",
|
||||
content: `Spec approved! Proceed with implementation.${result.modified ? " (spec was edited)" : ""}`,
|
||||
display: true,
|
||||
},
|
||||
{ deliverAs: "followUp" as any, triggerTurn: true },
|
||||
);
|
||||
ctx.ui.notify("Spec approved — continuing...", "info");
|
||||
} else if (result.action === "changes_requested") {
|
||||
const commentSummary = formatCommentsForAgent(result.comments);
|
||||
piRef.sendMessage(
|
||||
{
|
||||
customType: "spec-changes-requested",
|
||||
content: `Changes requested:\n\n${commentSummary}`,
|
||||
display: true,
|
||||
},
|
||||
{ deliverAs: "followUp" as any, triggerTurn: true },
|
||||
);
|
||||
ctx.ui.notify("Changes requested — reviewing comments...", "info");
|
||||
} else if (result.modified) {
|
||||
ctx.ui.notify("Spec was modified but no action taken.", "info");
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.ui.notify(`Error: ${err.message}`, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Session lifecycle ────────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
cleanupServer();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user