335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
import type { DiffType } from "./server.js";
|
|
import {
|
|
getLastAssistantMessageText,
|
|
getStartupErrorMessage,
|
|
openArchiveBrowserAction,
|
|
openCodeReview,
|
|
openLastMessageAnnotation,
|
|
openMarkdownAnnotation,
|
|
startCodeReviewBrowserSession,
|
|
startLastMessageAnnotationSession,
|
|
startMarkdownAnnotationSession,
|
|
startPlanReviewBrowserSession,
|
|
} from "./plannotator-browser.js";
|
|
|
|
export const PLANNOTATOR_REQUEST_CHANNEL = "plannotator:request" as const;
|
|
export const PLANNOTATOR_REVIEW_RESULT_CHANNEL = "plannotator:review-result" as const;
|
|
export const PLANNOTATOR_TIMEOUT_MS = 5_000;
|
|
|
|
export type PlannotatorAction =
|
|
| "plan-review"
|
|
| "review-status"
|
|
| "code-review"
|
|
| "annotate"
|
|
| "annotate-last"
|
|
| "archive";
|
|
|
|
export interface PlannotatorHandledResponse<T> {
|
|
status: "handled";
|
|
result: T;
|
|
}
|
|
|
|
export interface PlannotatorUnavailableResponse {
|
|
status: "unavailable";
|
|
error?: string;
|
|
}
|
|
|
|
export interface PlannotatorErrorResponse {
|
|
status: "error";
|
|
error: string;
|
|
}
|
|
|
|
export type PlannotatorResponse<T> =
|
|
| PlannotatorHandledResponse<T>
|
|
| PlannotatorUnavailableResponse
|
|
| PlannotatorErrorResponse;
|
|
|
|
export interface PlannotatorRequestBase<A extends PlannotatorAction, P, R> {
|
|
requestId: string;
|
|
action: A;
|
|
payload: P;
|
|
respond: (response: PlannotatorResponse<R>) => void;
|
|
}
|
|
|
|
export interface PlannotatorPlanReviewPayload {
|
|
planFilePath?: string;
|
|
planContent: string;
|
|
origin?: string;
|
|
}
|
|
|
|
export interface PlannotatorPlanReviewStartResult {
|
|
status: "pending";
|
|
reviewId: string;
|
|
}
|
|
|
|
export interface PlannotatorReviewResultEvent {
|
|
reviewId: string;
|
|
approved: boolean;
|
|
feedback?: string;
|
|
savedPath?: string;
|
|
agentSwitch?: string;
|
|
permissionMode?: string;
|
|
}
|
|
|
|
export interface PlannotatorReviewStatusPayload {
|
|
reviewId: string;
|
|
}
|
|
|
|
export type PlannotatorReviewStatusResult =
|
|
| { status: "pending" }
|
|
| ({ status: "completed" } & PlannotatorReviewResultEvent)
|
|
| { status: "missing" };
|
|
|
|
export interface PlannotatorCodeReviewPayload {
|
|
diffType?: DiffType;
|
|
defaultBranch?: string;
|
|
cwd?: string;
|
|
prUrl?: string;
|
|
}
|
|
|
|
export interface PlannotatorCodeReviewResult {
|
|
approved: boolean;
|
|
feedback?: string;
|
|
annotations?: unknown[];
|
|
agentSwitch?: string;
|
|
}
|
|
|
|
export interface PlannotatorAnnotatePayload {
|
|
filePath: string;
|
|
markdown?: string;
|
|
mode?: "annotate" | "annotate-folder" | "annotate-last";
|
|
folderPath?: string;
|
|
/** Enable review-gate UX (Approve / Annotate / Close), #570 */
|
|
gate?: boolean;
|
|
}
|
|
|
|
export interface PlannotatorAnnotationResult {
|
|
feedback: string;
|
|
/** True when the reviewer closed the session without providing feedback. */
|
|
exit?: boolean;
|
|
/** True when the reviewer clicked Approve in review-gate mode, #570 */
|
|
approved?: boolean;
|
|
}
|
|
|
|
export interface PlannotatorArchivePayload {
|
|
customPlanPath?: string;
|
|
}
|
|
|
|
export interface PlannotatorArchiveResult {
|
|
opened: boolean;
|
|
}
|
|
|
|
export type PlannotatorRequestMap = {
|
|
"plan-review": PlannotatorRequestBase<"plan-review", PlannotatorPlanReviewPayload, PlannotatorPlanReviewStartResult>;
|
|
"review-status": PlannotatorRequestBase<"review-status", PlannotatorReviewStatusPayload, PlannotatorReviewStatusResult>;
|
|
"code-review": PlannotatorRequestBase<"code-review", PlannotatorCodeReviewPayload, PlannotatorCodeReviewResult>;
|
|
annotate: PlannotatorRequestBase<"annotate", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>;
|
|
"annotate-last": PlannotatorRequestBase<"annotate-last", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>;
|
|
archive: PlannotatorRequestBase<"archive", PlannotatorArchivePayload, PlannotatorArchiveResult>;
|
|
};
|
|
export type PlannotatorRequest = PlannotatorRequestMap[PlannotatorAction];
|
|
export type PlannotatorResponseMap = {
|
|
"plan-review": PlannotatorResponse<PlannotatorPlanReviewStartResult>;
|
|
"review-status": PlannotatorResponse<PlannotatorReviewStatusResult>;
|
|
"code-review": PlannotatorResponse<PlannotatorCodeReviewResult>;
|
|
annotate: PlannotatorResponse<PlannotatorAnnotationResult>;
|
|
"annotate-last": PlannotatorResponse<PlannotatorAnnotationResult>;
|
|
archive: PlannotatorResponse<PlannotatorArchiveResult>;
|
|
};
|
|
function isPlannotatorAction(value: unknown): value is PlannotatorAction {
|
|
return (
|
|
value === "plan-review" ||
|
|
value === "review-status" ||
|
|
value === "code-review" ||
|
|
value === "annotate" ||
|
|
value === "annotate-last" ||
|
|
value === "archive"
|
|
);
|
|
}
|
|
|
|
const REVIEW_STATUS_PATH = join(homedir(), ".pi", "plannotator-review-status.json");
|
|
|
|
type StoredReviewStatus = Record<string, PlannotatorReviewStatusResult>;
|
|
|
|
function readStoredReviewStatuses(): StoredReviewStatus {
|
|
try {
|
|
if (!existsSync(REVIEW_STATUS_PATH)) return {};
|
|
const raw = readFileSync(REVIEW_STATUS_PATH, "utf-8");
|
|
const parsed = JSON.parse(raw) as StoredReviewStatus;
|
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeStoredReviewStatuses(statuses: StoredReviewStatus): void {
|
|
mkdirSync(dirname(REVIEW_STATUS_PATH), { recursive: true });
|
|
writeFileSync(REVIEW_STATUS_PATH, JSON.stringify(statuses, null, 2));
|
|
}
|
|
|
|
function setStoredReviewStatus(reviewId: string, status: PlannotatorReviewStatusResult): void {
|
|
const statuses = readStoredReviewStatuses();
|
|
statuses[reviewId] = status;
|
|
writeStoredReviewStatuses(statuses);
|
|
}
|
|
|
|
function getStoredReviewStatus(reviewId: string): PlannotatorReviewStatusResult {
|
|
return readStoredReviewStatuses()[reviewId] ?? { status: "missing" };
|
|
}
|
|
|
|
function createActiveSessionContext() {
|
|
let currentCtx: ExtensionContext | undefined;
|
|
|
|
return {
|
|
set(ctx: ExtensionContext): void {
|
|
currentCtx = ctx;
|
|
},
|
|
clear(): void {
|
|
currentCtx = undefined;
|
|
},
|
|
get(): ExtensionContext | undefined {
|
|
return currentCtx;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function registerPlannotatorEventListeners(pi: ExtensionAPI): void {
|
|
const activeSessionContext = createActiveSessionContext();
|
|
|
|
// Plannotator event requests are handled against the latest active session.
|
|
// The active context is intentionally session-scoped and replaced on each session_start.
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
activeSessionContext.set(ctx);
|
|
});
|
|
pi.events.on(PLANNOTATOR_REQUEST_CHANNEL, async (data) => {
|
|
const request = data as Partial<PlannotatorRequest> | null;
|
|
const ctx = activeSessionContext.get();
|
|
|
|
if (!request || typeof request.respond !== "function" || !isPlannotatorAction(request.action)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (request.action === "review-status") {
|
|
const reviewId = request.payload?.reviewId;
|
|
if (typeof reviewId !== "string" || !reviewId.trim()) {
|
|
request.respond({ status: "error", error: "Missing reviewId for review-status request." });
|
|
return;
|
|
}
|
|
request.respond({ status: "handled", result: getStoredReviewStatus(reviewId) });
|
|
return;
|
|
}
|
|
|
|
if (!ctx) {
|
|
request.respond({ status: "unavailable", error: "Plannotator context is not ready yet." });
|
|
return;
|
|
}
|
|
|
|
switch (request.action) {
|
|
case "plan-review": {
|
|
const planContent = request.payload?.planContent;
|
|
if (typeof planContent !== "string" || !planContent.trim()) {
|
|
request.respond({ status: "error", error: "Missing planContent for plan-review request." });
|
|
return;
|
|
}
|
|
const session = await startPlanReviewBrowserSession(ctx, planContent);
|
|
setStoredReviewStatus(session.reviewId, { status: "pending" });
|
|
session.onDecision((result) => {
|
|
const reviewResult = {
|
|
reviewId: session.reviewId,
|
|
approved: result.approved,
|
|
feedback: result.feedback,
|
|
savedPath: result.savedPath,
|
|
agentSwitch: result.agentSwitch,
|
|
permissionMode: result.permissionMode,
|
|
} satisfies PlannotatorReviewResultEvent;
|
|
setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult });
|
|
pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult);
|
|
});
|
|
request.respond({
|
|
status: "handled",
|
|
result: {
|
|
status: "pending",
|
|
reviewId: session.reviewId,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
case "code-review": {
|
|
const result = await openCodeReview(ctx, {
|
|
cwd: request.payload?.cwd,
|
|
defaultBranch: request.payload?.defaultBranch,
|
|
diffType: request.payload?.diffType,
|
|
prUrl: request.payload?.prUrl,
|
|
});
|
|
request.respond({ status: "handled", result });
|
|
return;
|
|
}
|
|
case "annotate": {
|
|
const payload = request.payload;
|
|
if (!payload?.filePath) {
|
|
request.respond({ status: "error", error: "Missing filePath for annotate request." });
|
|
return;
|
|
}
|
|
const sourceConverted = /\.html?$/i.test(payload.filePath) || /^https?:\/\//i.test(payload.filePath);
|
|
const result = await openMarkdownAnnotation(
|
|
ctx,
|
|
payload.filePath,
|
|
payload.markdown ?? "",
|
|
payload.mode ?? "annotate",
|
|
payload.folderPath,
|
|
undefined,
|
|
sourceConverted,
|
|
payload.gate,
|
|
);
|
|
request.respond({ status: "handled", result });
|
|
return;
|
|
}
|
|
case "annotate-last": {
|
|
const payload = request.payload;
|
|
const lastText = payload?.markdown?.trim() ? payload.markdown : getLastAssistantMessageText(ctx);
|
|
if (!lastText) {
|
|
request.respond({ status: "unavailable", error: "No assistant message found in session." });
|
|
return;
|
|
}
|
|
const result = await openLastMessageAnnotation(ctx, lastText, payload?.gate);
|
|
request.respond({ status: "handled", result });
|
|
return;
|
|
}
|
|
case "archive": {
|
|
const result = await openArchiveBrowserAction(ctx, request.payload?.customPlanPath);
|
|
request.respond({ status: "handled", result });
|
|
return;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const message = getStartupErrorMessage(err);
|
|
if (/unavailable|not available/i.test(message)) {
|
|
request.respond({ status: "unavailable", error: message });
|
|
return;
|
|
}
|
|
request.respond({ status: "error", error: message });
|
|
}
|
|
});
|
|
}
|
|
|
|
export {
|
|
getLastAssistantMessageText,
|
|
hasPlanBrowserHtml,
|
|
hasReviewBrowserHtml,
|
|
startCodeReviewBrowserSession,
|
|
startLastMessageAnnotationSession,
|
|
startMarkdownAnnotationSession,
|
|
getStartupErrorMessage,
|
|
openArchiveBrowserAction,
|
|
openCodeReview,
|
|
openLastMessageAnnotation,
|
|
openMarkdownAnnotation,
|
|
openPlanReviewBrowser,
|
|
startPlanReviewBrowserSession,
|
|
} from "./plannotator-browser.js";
|