Skip to content

Commit 2a55286

Browse files
authored
Add Ask AI to plan and annotate reviews (#763)
* Add Ask AI to plan and annotate reviews - mount shared AI runtime on plan, annotate, review, and Pi servers - add shared document chat UI and comment-popover Ask AI entry point - default providers from detected agent origin and document first-run announcement - reuse shared code-review AI hook and document setup/docs * Address Ask AI review followups Stabilize AI chat session resets and callbacks, preserve per-origin provider defaults, share AI chat formatting utilities, and avoid blocking startup on model discovery. * Fix AI permission details and Pi CLI lookup Show formatted tool input in document AI permission cards and make Pi AI provider CLI detection work on Windows. * Fix Pi model discovery in AI capabilities Keep AI runtime startup non-blocking while making the capabilities endpoint await pending Pi/OpenCode model discovery before returning provider metadata.
1 parent fa4482d commit 2a55286

52 files changed

Lines changed: 2499 additions & 937 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ claude --plugin-dir ./apps/hook
121121
| `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. |
122122
| `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. |
123123
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. |
124-
| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`. Invalid values silently fall through to env-based detection. Unset by default. |
124+
| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`, `pi`. Invalid values silently fall through to env-based detection. Unset by default. |
125125
| `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "jina": false }`) or per-invocation via `--no-jina`. |
126126
| `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. |
127127
| `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via `~/.plannotator/config.json` (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires `gh` installed and authenticated. |
@@ -173,6 +173,21 @@ Send Feedback → feedback sent to agent session
173173
Approve → "LGTM" sent to agent session
174174
```
175175

176+
## Ask AI Provider Defaults
177+
178+
Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in `packages/shared/agents.ts` and is applied by `packages/ui/utils/aiProvider.ts`:
179+
180+
| Origin | Preferred Ask AI provider |
181+
|--------|---------------------------|
182+
| `claude-code` | `claude-agent-sdk` |
183+
| `codex` | `codex-sdk` |
184+
| `opencode` | `opencode-sdk` |
185+
| `pi` | `pi-sdk` |
186+
| `copilot-cli` | no dedicated provider; fallback to saved/server default |
187+
| `gemini-cli` | no dedicated provider; fallback to saved/server default |
188+
189+
Per-origin choices are persisted in cookies, so a user can override the automatic match for one agent without changing the default for another.
190+
176191
## Annotate Flow
177192

178193
```
@@ -235,6 +250,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
235250
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
236251
| `/api/editor-annotations` | GET | List editor annotations (VS Code only) |
237252
| `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) |
253+
| `/api/ai/capabilities` | GET | Check if AI features are available |
254+
| `/api/ai/session` | POST | Create or fork an AI session |
255+
| `/api/ai/query` | POST | Send a message and stream the response (SSE) |
256+
| `/api/ai/abort` | POST | Abort the current query |
257+
| `/api/ai/permission` | POST | Respond to a permission request |
258+
| `/api/ai/sessions` | GET | List active sessions |
238259
| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations |
239260
| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) |
240261
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
@@ -293,6 +314,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
293314
| `/api/doc` | GET | Serve linked .md/.mdx/.html file or code file (`?path=<path>&base=<dir>`) |
294315
| `/api/doc/exists` | POST | Batch-validate code-file paths (body: `{ paths: string[], base?: string }`) |
295316
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
317+
| `/api/ai/capabilities` | GET | Check if AI features are available |
318+
| `/api/ai/session` | POST | Create or fork an AI session |
319+
| `/api/ai/query` | POST | Send a message and stream the response (SSE) |
320+
| `/api/ai/abort` | POST | Abort the current query |
321+
| `/api/ai/permission` | POST | Respond to a permission request |
322+
| `/api/ai/sessions` | GET | List active sessions |
296323
| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations |
297324
| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) |
298325
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ Interactive Plan & Code Review for AI Coding Agents. Mark up and refine your pla
3838
### Features
3939

4040
<table>
41-
<tr><td><strong>Visual Plan Review</strong></td><td>Built-in hook</td><td>Approve or deny agent plans with inline annotations</td></tr>
41+
<tr><td><strong>Visual Plan Review</strong></td><td>Built-in hook</td><td>Approve or deny agent plans with inline annotations and Ask AI side chat</td></tr>
4242
<tr><td><strong>Plan Diff</strong></td><td>Automatic</td><td>See what changed when the agent revises a plan</td></tr>
4343
<tr><td><strong>Code Review</strong></td><td><code>/plannotator-review</code></td><td>View git diffs or remote PRs. Package annotations and ask AI about the code as you review.</td></tr>
44-
<tr><td><strong>Annotate Any File</strong></td><td><code>/plannotator-annotate &lt;file|folder|url&gt;</code></td><td>Annotate markdown, HTML, URLs, or folders and send feedback to your agent</td></tr>
44+
<tr><td><strong>Annotate Any File</strong></td><td><code>/plannotator-annotate &lt;file|folder|url&gt;</code></td><td>Annotate markdown, HTML, URLs, or folders, ask AI about the active document, and send feedback to your agent</td></tr>
4545
<tr><td><strong>Annotate Last Message</strong></td><td><code>/plannotator-last</code></td><td>Annotate the agent's last response and send structured feedback</td></tr>
4646
</table>
4747

@@ -255,8 +255,9 @@ When your AI agent finishes planning, Plannotator:
255255

256256
1. Opens the Plannotator UI in your browser
257257
2. Lets you annotate the plan visually (delete, insert, replace, comment)
258-
3. **Approve** → Agent proceeds with implementation
259-
4. **Request changes** → Your annotations are sent back as structured feedback
258+
3. Lets you ask AI about the plan or a highlighted selection when a provider is available
259+
4. **Approve** → Agent proceeds with implementation
260+
5. **Request changes** → Your annotations are sent back as structured feedback
260261

261262
(Similar flow for code review, except you can also comment on specific lines of code diffs)
262263

apps/marketing/src/content/docs/guides/ai-features.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
---
22
title: AI Features
3-
description: "How to use Plannotator's inline AI chat during code review — provider setup, model selection, and how it works."
3+
description: "How to use Plannotator's AI chat during plan review, annotate, and code review — provider setup, model selection, and how it works."
44
sidebar:
55
order: 25
66
section: "Guides"
77
---
88

9-
Plannotator embeds an AI chat sidebar directly in the code review UI. You can select lines in a diff, ask questions, and get streaming responses. The AI sees the full diff context automatically, so you can ask things like "explain this change" or "is this safe?" without copy-pasting code.
9+
Plannotator embeds an AI chat sidebar directly in live review sessions. In plan review and annotate, you can ask a general question about the current plan or document, or select text, open the comment popover, and choose **Ask AI**. In code review, you can select lines in a diff and ask questions about the code.
10+
11+
The AI sees the relevant review context automatically: the current plan and previous plan version for plan review, the active document and source metadata for annotate, or the full diff for code review. AI chat history stays separate from approve, deny, and send-annotations output unless you manually copy text into normal feedback.
1012

1113
## Supported providers
1214

@@ -49,6 +51,8 @@ OpenCode supports session forking, resuming, and runtime permission approvals
4951

5052
Provider and model selection is available in **Settings > AI**. These persist via cookies across sessions.
5153

54+
By default, Plannotator prefers the provider that matches the detected agent origin: Claude Code uses Claude, Codex uses Codex, OpenCode uses OpenCode, and Pi uses Pi when those providers are available. GitHub Copilot CLI and Gemini CLI do not have dedicated Ask AI providers yet, so they fall back to your saved provider or the server default.
55+
5256
You can also override the provider and model per-session using the config bar at the bottom of the AI sidebar. Changing the provider or model starts a new session — old messages stay visible but the conversation resets.
5357

5458
## How it works
@@ -63,7 +67,7 @@ A session is created lazily on your first question. Until then, no resources are
6367

6468
**OpenCode sessions** pass the review context via the `system` field on the prompt API. OpenCode supports forking from a parent session and resuming previous sessions. Permission requests work the same as Claude — approval cards appear inline.
6569

66-
**Diff context handling:** Large diffs are truncated at roughly 40k characters to stay within context limits. However, when you select specific lines and ask a question, the selected code is always sent alongside the question regardless of truncation.
70+
**Context handling:** Large plans, documents, and diffs are truncated to stay within context limits. When you ask from a selection, the selected text or selected code is always sent alongside the question regardless of truncation. In folder annotation mode, Ask AI is scoped to the currently opened document only.
6771

6872
## Permission requests
6973

apps/pi-extension/server.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,7 @@ describe("pi review server", () => {
511511

512512
const vcsContext = await getVcsContext(repoDir);
513513
expect(vcsContext.vcsType).toBe("jj");
514+
const expectedJjBase = vcsContext.defaultBranch;
514515
const prepared = await prepareLocalReviewDiff({
515516
cwd: repoDir,
516517
requestedDiffType: "merge-base",
@@ -519,7 +520,7 @@ describe("pi review server", () => {
519520
});
520521
expect(prepared.gitContext.vcsType).toBe("jj");
521522
expect(prepared.diffType).toBe("jj-current");
522-
expect(prepared.base).toBe("main@git");
523+
expect(prepared.base).toBe(expectedJjBase);
523524

524525
const forcedGit = await prepareLocalReviewDiff({
525526
cwd: repoDir,
@@ -578,14 +579,13 @@ describe("pi review server", () => {
578579
gitContext?: { vcsType?: string; diffOptions: Array<{ id: string }> };
579580
};
580581
expect(initial.diffType).toBe("jj-current");
581-
expect(initial.base).toBe("main@git");
582+
expect(initial.base).toBe(expectedJjBase);
582583
expect(initial.gitContext?.vcsType).toBe("jj");
583-
expect(initial.gitContext?.diffOptions.map((option) => option.id)).toEqual([
584-
"jj-current",
585-
"jj-last",
586-
"jj-line",
587-
"jj-all",
588-
]);
584+
const optionIds = initial.gitContext?.diffOptions.map((option) => option.id) ?? [];
585+
expect(optionIds).toContain("jj-current");
586+
expect(optionIds).toContain("jj-last");
587+
expect(optionIds).toContain("jj-line");
588+
expect(optionIds).toContain("jj-all");
589589
expect(initial.rawPatch).toContain("tracked.txt");
590590
expect(initial.rawPatch).toContain("+after");
591591

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { execFileSync } from "node:child_process";
2+
import type { IncomingMessage, ServerResponse } from "node:http";
3+
import { Readable } from "node:stream";
4+
5+
import { json, toWebRequest } from "./helpers.js";
6+
7+
export interface PiAIRuntime {
8+
endpoints: Record<string, (req: Request) => Promise<Response>>;
9+
dispose: () => void;
10+
}
11+
12+
interface CreatePiAIRuntimeOptions {
13+
cwd?: string;
14+
getCwd?: () => string;
15+
}
16+
17+
function whichCmd(cmd: string): string | null {
18+
try {
19+
const bin = process.platform === "win32" ? "where" : "which";
20+
const output = execFileSync(bin, [cmd], {
21+
encoding: "utf-8",
22+
stdio: ["pipe", "pipe", "pipe"],
23+
});
24+
return output
25+
.split(/\r?\n/)
26+
.map((line) => line.trim())
27+
.find(Boolean) ?? null;
28+
} catch {
29+
return null;
30+
}
31+
}
32+
33+
export async function createPiAIRuntime(options: CreatePiAIRuntimeOptions = {}): Promise<PiAIRuntime | null> {
34+
try {
35+
const ai = await import("../generated/ai/index.js");
36+
const cwd = options.cwd ?? process.cwd();
37+
const registry = new ai.ProviderRegistry();
38+
const sessionManager = new ai.SessionManager();
39+
const modelDiscovery: Promise<void>[] = [];
40+
41+
try {
42+
await import("../generated/ai/providers/claude-agent-sdk.js");
43+
const claudePath = whichCmd("claude");
44+
const provider = await ai.createProvider({
45+
type: "claude-agent-sdk",
46+
cwd,
47+
...(claudePath && { claudeExecutablePath: claudePath }),
48+
});
49+
registry.register(provider);
50+
} catch {
51+
// Claude SDK not available.
52+
}
53+
54+
try {
55+
await import("../generated/ai/providers/codex-sdk.js");
56+
await import("@openai/codex-sdk");
57+
const codexPath = whichCmd("codex");
58+
const provider = await ai.createProvider({
59+
type: "codex-sdk",
60+
cwd,
61+
...(codexPath && { codexExecutablePath: codexPath }),
62+
});
63+
registry.register(provider);
64+
} catch {
65+
// Codex SDK not available.
66+
}
67+
68+
try {
69+
await import("../generated/ai/providers/pi-sdk-node.js");
70+
const piPath = whichCmd("pi");
71+
if (piPath) {
72+
const provider = await ai.createProvider({
73+
type: "pi-sdk",
74+
cwd,
75+
piExecutablePath: piPath,
76+
} as any);
77+
if (provider && "fetchModels" in provider) {
78+
modelDiscovery.push(
79+
(provider as { fetchModels: () => Promise<void> })
80+
.fetchModels()
81+
.catch(() => {}),
82+
);
83+
}
84+
registry.register(provider);
85+
}
86+
} catch {
87+
// Pi not available.
88+
}
89+
90+
try {
91+
await import("../generated/ai/providers/opencode-sdk.js");
92+
const opencodePath = whichCmd("opencode");
93+
if (opencodePath) {
94+
const provider = await ai.createProvider({
95+
type: "opencode-sdk",
96+
cwd,
97+
});
98+
if (provider && "fetchModels" in provider) {
99+
modelDiscovery.push(
100+
(provider as { fetchModels: () => Promise<void> })
101+
.fetchModels()
102+
.catch(() => {}),
103+
);
104+
}
105+
registry.register(provider);
106+
}
107+
} catch {
108+
// OpenCode not available.
109+
}
110+
111+
return {
112+
endpoints: ai.createAIEndpoints({
113+
registry,
114+
sessionManager,
115+
getCwd: options.getCwd,
116+
beforeCapabilities: async () => {
117+
await Promise.allSettled(modelDiscovery);
118+
},
119+
}),
120+
dispose: () => {
121+
sessionManager.disposeAll();
122+
registry.disposeAll();
123+
},
124+
};
125+
} catch {
126+
return null;
127+
}
128+
}
129+
130+
export async function handlePiAIRequest(
131+
req: IncomingMessage,
132+
res: ServerResponse,
133+
url: URL,
134+
runtime: PiAIRuntime | null,
135+
): Promise<boolean> {
136+
if (!url.pathname.startsWith("/api/ai/")) return false;
137+
138+
if (!runtime) {
139+
if (url.pathname === "/api/ai/capabilities" && req.method === "GET") {
140+
json(res, { available: false, providers: [] });
141+
return true;
142+
}
143+
json(res, { error: "AI backend not available" }, 503);
144+
return true;
145+
}
146+
147+
const handler = runtime.endpoints[url.pathname];
148+
if (!handler) {
149+
json(res, { error: "Not found" }, 404);
150+
return true;
151+
}
152+
153+
try {
154+
const webReq = toWebRequest(req);
155+
const webRes = await handler(webReq);
156+
const headers: Record<string, string> = {};
157+
webRes.headers.forEach((value, key) => {
158+
headers[key] = value;
159+
});
160+
res.writeHead(webRes.status, headers);
161+
if (webRes.body) {
162+
Readable.fromWeb(webRes.body as any).pipe(res);
163+
} else {
164+
res.end();
165+
}
166+
} catch (err) {
167+
json(res, { error: err instanceof Error ? err.message : "AI endpoint error" }, 500);
168+
}
169+
170+
return true;
171+
}

apps/pi-extension/server/serverAnnotate.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
handleUploadRequest,
1212
} from "./handlers.js";
1313
import { html, json, parseBody, requestUrl } from "./helpers.js";
14+
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js";
1415

1516
import { listenOnPort } from "./network.js";
1617

@@ -86,11 +87,13 @@ export async function startAnnotateServer(options: {
8687
const repoInfo = getRepoInfo();
8788

8889
const externalAnnotations = createExternalAnnotationHandler("plan");
90+
const aiRuntime = await createPiAIRuntime();
8991

9092
const server = createServer(async (req, res) => {
9193
const url = requestUrl(req);
9294

9395
if (await externalAnnotations.handle(req, res, url)) return;
96+
if (url.pathname.startsWith("/api/ai/") && await handlePiAIRequest(req, res, url, aiRuntime)) return;
9497

9598
if (url.pathname === "/api/plan" && req.method === "GET") {
9699
json(res, {
@@ -180,6 +183,9 @@ export async function startAnnotateServer(options: {
180183
portSource,
181184
url: `http://localhost:${port}`,
182185
waitForDecision: () => decisionPromise,
183-
stop: () => server.close(),
186+
stop: () => {
187+
aiRuntime?.dispose();
188+
server.close();
189+
},
184190
};
185191
}

apps/pi-extension/server/serverPlan.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
handleUploadRequest,
2525
} from "./handlers.js";
2626
import { html, json, parseBody, requestUrl } from "./helpers.js";
27+
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js";
2728
import { openEditorDiff } from "./ide.js";
2829
import {
2930
type BearConfig,
@@ -156,6 +157,7 @@ export async function startPlanReviewServer(options: {
156157
// Editor annotations (in-memory, VS Code integration — skip in archive mode)
157158
const editorAnnotations = options.mode !== "archive" ? createEditorAnnotationHandler() : null;
158159
const externalAnnotations = options.mode !== "archive" ? createExternalAnnotationHandler("plan") : null;
160+
const aiRuntime = options.mode !== "archive" ? await createPiAIRuntime() : null;
159161

160162
// Lazy cache for in-session archive tab
161163
let cachedArchivePlans: ArchivedPlan[] | null = null;
@@ -267,6 +269,8 @@ export async function startPlanReviewServer(options: {
267269
return;
268270
} else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) {
269271
return;
272+
} else if (url.pathname.startsWith("/api/ai/") && await handlePiAIRequest(req, res, url, aiRuntime)) {
273+
return;
270274
} else if (url.pathname === "/api/doc" && req.method === "GET") {
271275
await handleDocRequest(res, url);
272276
} else if (url.pathname === "/api/doc/exists" && req.method === "POST") {
@@ -493,6 +497,9 @@ export async function startPlanReviewServer(options: {
493497
};
494498
},
495499
...(donePromise && { waitForDone: () => donePromise }),
496-
stop: () => server.close(),
500+
stop: () => {
501+
aiRuntime?.dispose();
502+
server.close();
503+
},
497504
};
498505
}

0 commit comments

Comments
 (0)