Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ User runs /plannotator-review command
Claude Code: plannotator review subcommand runs
OpenCode: event handler intercepts command
VCS diff captures local changes (git diff or jj diff)
VCS diff captures local changes (git diff or jj diff). When review runs from a
non-VCS parent that contains nested Git repos, child diffs are combined with
folder-prefixed paths.
Review server starts, opens browser with diff viewer
Expand Down Expand Up @@ -266,7 +268,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via

| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, diffType, base, hideWhitespace, gitContext }` |
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, mode?, diffType, base, hideWhitespace, gitContext, agentCwd? }`. Workspace mode returns `mode: "workspace"` with folder-prefixed paths and no `gitContext`. |
| `/api/diff/switch` | POST | Switch diff type, base branch, or whitespace mode (body: `{ diffType, base?, hideWhitespace? }`) |
| `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context (`?path=&oldPath=&base=`) |
| `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) |
Expand Down
48 changes: 35 additions & 13 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
startGoalSetupServer,
handleGoalSetupServerReady,
} from "@plannotator/server/goal-setup";
import { type DiffType, prepareLocalReviewDiff, gitRuntime } from "@plannotator/server/vcs";
import { type DiffType, gitRuntime, prepareLocalReviewDiff, detectManagedVcs } from "@plannotator/server/vcs";
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
import { parseReviewArgs } from "@plannotator/shared/review-args";
import {
Expand Down Expand Up @@ -127,6 +127,7 @@ import {
} from "./cli";
import path from "path";
import { tmpdir } from "os";
import { buildLocalWorkspaceReview, type WorkspaceDiffType } from "@plannotator/server/review-workspace";

// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
Expand Down Expand Up @@ -401,10 +402,11 @@ if (args[0] === "sessions") {
let diffError: string | undefined;
let gitContext: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
let initialDiffType: DiffType | undefined;
let initialDiffType: DiffType | WorkspaceDiffType | undefined;
let agentCwd: string | undefined;
let worktreePool: WorktreePool | undefined;
let worktreeCleanup: (() => void | Promise<void>) | undefined;
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;

if (isPRMode) {
// --- PR Review Mode ---
Expand Down Expand Up @@ -595,16 +597,35 @@ if (args[0] === "sessions") {
} else {
// --- Local Review Mode ---
const config = loadConfig();
const diffResult = await prepareLocalReviewDiff({
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
initialDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
const managedVcs = await detectManagedVcs(process.cwd(), reviewArgs.vcsType);
const forcedVcs = !!reviewArgs.vcsType && reviewArgs.vcsType !== "auto";

if (managedVcs || forcedVcs) {
const diffResult = await prepareLocalReviewDiff({
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
initialDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
} else {
workspace = await buildLocalWorkspaceReview(process.cwd(), {
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
if (workspace.repos.length === 0) {
console.error("Not in a VCS repo and no nested Git/JJ repositories were found.");
process.exit(1);
}
rawPatch = workspace.rawPatch;
gitRef = workspace.gitRef;
diffError = workspace.error;
initialDiffType = workspace.diffType;
agentCwd = workspace.root;
}
}

const reviewProject = (await detectProjectName()) ?? "_unknown";
Expand All @@ -615,9 +636,10 @@ if (args[0] === "sessions") {
gitRef,
error: diffError,
origin: detectedOrigin,
diffType: gitContext ? (initialDiffType ?? "unstaged") : undefined,
diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : undefined,
gitContext,
prMetadata,
workspace,
agentCwd,
worktreePool,
sharingEnabled,
Expand Down
55 changes: 42 additions & 13 deletions apps/opencode-plugin/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { type DiffType, prepareLocalReviewDiff } from "@plannotator/server/vcs";
import { type DiffType, prepareLocalReviewDiff, detectManagedVcs } from "@plannotator/server/vcs";
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
import {
Expand All @@ -32,6 +32,7 @@ import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
import { parseAnnotateArgs } from "@plannotator/shared/annotate-args";
import { parseReviewArgs } from "@plannotator/shared/review-args";
import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown";
import { buildLocalWorkspaceReview, type WorkspaceDiffType } from "@plannotator/server/review-workspace";
import { statSync } from "fs";
import path from "path";

Expand Down Expand Up @@ -60,9 +61,11 @@ export async function handleReviewCommand(
let rawPatch: string;
let gitRef: string;
let diffError: string | undefined;
let userDiffType: DiffType | undefined;
let userDiffType: DiffType | WorkspaceDiffType | undefined;
let gitContext: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;
let agentCwd: string | undefined;

if (isPRMode) {
const prRef = parsePRUrl(urlArg);
Expand Down Expand Up @@ -94,17 +97,41 @@ export async function handleReviewCommand(
client.app.log({ level: "info", message: "Opening code review UI..." });

const config = loadConfig();
const diffResult = await prepareLocalReviewDiff({
cwd: directory,
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
userDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
const cwd = directory ?? process.cwd();
const managedVcs = await detectManagedVcs(cwd, reviewArgs.vcsType);
const forcedVcs = !!reviewArgs.vcsType && reviewArgs.vcsType !== "auto";
if (managedVcs || forcedVcs) {
try {
const diffResult = await prepareLocalReviewDiff({
cwd,
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
userDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
} catch (err) {
client.app.log({ level: "error", message: err instanceof Error ? err.message : "Failed to prepare local review diff" });
return;
}
} else {
workspace = await buildLocalWorkspaceReview(cwd, {
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
if (workspace.repos.length === 0) {
client.app.log({ level: "error", message: "Not in a VCS repo and no nested Git/JJ repositories were found." });
return;
}
rawPatch = workspace.rawPatch;
gitRef = workspace.gitRef;
diffError = workspace.error;
userDiffType = workspace.diffType;
agentCwd = workspace.root;
}
}

const server = await startReviewServer({
Expand All @@ -115,6 +142,8 @@ export async function handleReviewCommand(
diffType: isPRMode ? undefined : userDiffType,
gitContext,
prMetadata,
workspace,
agentCwd,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent: reviewHtmlContent,
Expand Down
81 changes: 63 additions & 18 deletions apps/pi-extension/plannotator-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
prepareLocalReviewDiff,
reviewRuntime,
detectManagedVcs,
getVcsContext,
getVcsFileContentsForDiff,
canStageFiles,
runVcsDiff,
stageFile,
startAnnotateServer,
startPlanReviewServer,
startReviewServer,
type DiffType,
type VcsSelection,
unstageFile,
} from "./server.js";
import { openBrowser, isRemoteSession } from "./server/network.js";
import { parsePRUrl, checkPRAuth, fetchPR } from "./server/pr.js";
Expand All @@ -26,6 +33,10 @@ import {
import { parseRemoteUrl } from "./generated/repo.js";
import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "./generated/worktree.js";
import { loadConfig, resolveDefaultDiffType } from "./generated/config.js";
import {
WorkspaceReviewSession,
type WorkspaceDiffType,
} from "./generated/review-workspace.js";
export { getLastAssistantMessageText } from "./assistant-message.js";

export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last";
Expand Down Expand Up @@ -89,6 +100,20 @@ function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): void {
}
}

async function buildLocalWorkspaceReview(
root: string,
options: { requestedDiffType?: DiffType | WorkspaceDiffType; configuredDiffType?: DiffType; hideWhitespace?: boolean } = {},
): Promise<WorkspaceReviewSession> {
return WorkspaceReviewSession.create({
getVcsContext,
runVcsDiff,
getVcsFileContentsForDiff,
canStageFiles,
stageFile,
unstageFile,
}, root, options);
}

async function openBrowserAndWait<T>(
server: { url: string; stop: () => void },
ctx: ExtensionContext,
Expand Down Expand Up @@ -226,12 +251,13 @@ export async function startCodeReviewBrowserSession(
let diffError: string | undefined;
let gitCtx: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
let diffType: DiffType | undefined;
let diffType: DiffType | WorkspaceDiffType | undefined;
let agentCwd: string | undefined;
let initialBase: string | undefined;
let worktreeCleanup: (() => void | Promise<void>) | undefined;
let worktreePool: WorktreePool | undefined;
let exitHandler: (() => void) | undefined;
let workspace: WorkspaceReviewSession | undefined;

if (isPRMode && urlArg) {
// --- PR Review Mode ---
Expand Down Expand Up @@ -399,23 +425,41 @@ export async function startCodeReviewBrowserSession(
// --- Local Review Mode ---
const cwd = options.cwd ?? ctx.cwd;
const config = loadConfig();
const result = await prepareLocalReviewDiff({
cwd,
vcsType: options.vcsType,
requestedDiffType: options.diffType,
requestedBase: options.defaultBranch,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitCtx = result.gitContext;
diffType = result.diffType;
rawPatch = result.rawPatch;
gitRef = result.gitRef;
diffError = result.error;
// Remember which base the initial diff was computed against so it can
// be forwarded to the server below. Only matters when the caller
// overrode the detected default; otherwise it matches gitCtx already.
initialBase = result.base;
const managedVcs = await detectManagedVcs(cwd, options.vcsType);
const forcedVcs = !!options.vcsType && options.vcsType !== "auto";
if (managedVcs || forcedVcs) {
const result = await prepareLocalReviewDiff({
cwd,
vcsType: options.vcsType,
requestedDiffType: options.diffType,
requestedBase: options.defaultBranch,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitCtx = result.gitContext;
diffType = result.diffType;
rawPatch = result.rawPatch;
gitRef = result.gitRef;
diffError = result.error;
// Remember which base the initial diff was computed against so it can
// be forwarded to the server below. Only matters when the caller
// overrode the detected default; otherwise it matches gitCtx already.
initialBase = result.base;
} else {
workspace = await buildLocalWorkspaceReview(cwd, {
requestedDiffType: options.diffType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
if (workspace.repos.length === 0) {
throw new Error("Not in a VCS repo and no nested Git/JJ repositories were found.");
}
rawPatch = workspace.rawPatch;
gitRef = workspace.gitRef;
diffError = workspace.error;
diffType = workspace.diffType;
agentCwd = workspace.root;
}
}

const server = await startReviewServer({
Expand All @@ -427,6 +471,7 @@ export async function startCodeReviewBrowserSession(
gitContext: gitCtx,
initialBase,
prMetadata,
workspace,
agentCwd,
worktreePool,
htmlContent: reviewHtmlContent,
Expand Down
Loading