Skip to content

Commit 8019f73

Browse files
authored
Feat/code review system (#57)
## Summary Complete code review system for reviewing git diffs with annotations. ### Features - Interactive diff viewer with split/unified views - Line-level annotation system - Diff type selector (uncommitted, last commit, vs main branch) - Dynamic default branch detection - Empty state handling - Simplified UX with streamlined feedback flow Closes #51 Closes #56
1 parent 459a089 commit 8019f73

38 files changed

Lines changed: 5454 additions & 252 deletions

.github/workflows/release.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ jobs:
2323
run: bun install
2424

2525
- name: Build UI
26-
run: bun run build:hook
26+
run: |
27+
bun run build:review
28+
bun run build:hook
2729
2830
- name: Compile binaries (cross-compile all targets)
2931
run: |

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ dist-ssr
1515
# npm pack output
1616
*.tgz
1717

18-
# OpenCode plugin build artifact (generated from hook dist)
18+
# OpenCode plugin build artifacts (generated from hook/review dist)
1919
apps/opencode-plugin/plannotator.html
20+
apps/opencode-plugin/review-editor.html
2021

2122
# Editor directories and files
2223
.vscode/*

CLAUDE.md

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Plannotator
22

3-
A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback.
3+
A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs.
44

55
## Project Structure
66

@@ -9,15 +9,23 @@ plannotator/
99
├── apps/
1010
│ ├── hook/ # Claude Code plugin
1111
│ │ ├── .claude-plugin/plugin.json
12+
│ │ ├── commands/ # Slash commands (plannotator-review.md)
1213
│ │ ├── hooks/hooks.json # PermissionRequest hook config
13-
│ │ ├── server/index.ts # Entry point (reads stdin, outputs decision)
14-
│ │ └── dist/index.html # Built single-file app
15-
│ └── opencode-plugin/ # OpenCode plugin
16-
│ ├── index.ts # Plugin entry with submit_plan tool
17-
│ └── plannotator.html # Built single-file app (copied from hook)
14+
│ │ ├── server/index.ts # Entry point (plan + review subcommand)
15+
│ │ └── dist/ # Built single-file apps (index.html, review.html)
16+
│ ├── opencode-plugin/ # OpenCode plugin
17+
│ │ ├── commands/ # Slash commands (plannotator-review.md)
18+
│ │ ├── index.ts # Plugin entry with submit_plan tool + review event handler
19+
│ │ ├── plannotator.html # Built plan review app
20+
│ │ └── review-editor.html # Built code review app
21+
│ └── review/ # Standalone review server (for development)
22+
│ ├── index.html
23+
│ ├── index.tsx
24+
│ └── vite.config.ts
1825
├── packages/
1926
│ ├── server/ # Shared server implementation
2027
│ │ ├── index.ts # startPlannotatorServer(), handleServerReady()
28+
│ │ ├── review.ts # startReviewServer(), handleReviewServerReady()
2129
│ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.)
2230
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
2331
│ │ ├── browser.ts # openBrowser()
@@ -28,7 +36,12 @@ plannotator/
2836
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts
2937
│ │ ├── hooks/ # useSharing.ts
3038
│ │ └── types.ts
31-
│ └── editor/ # Main App.tsx
39+
│ ├── editor/ # Plan review App.tsx
40+
│ └── review-editor/ # Code review UI
41+
│ ├── App.tsx # Main review app
42+
│ ├── components/ # DiffViewer, FileTree, ReviewPanel
43+
│ ├── demoData.ts # Demo diff for standalone mode
44+
│ └── index.css # Review-specific styles
3245
├── .claude-plugin/marketplace.json # For marketplace install
3346
└── legacy/ # Old pre-monorepo code (reference only)
3447
```
@@ -63,7 +76,7 @@ export PLANNOTATOR_REMOTE=1
6376
export PLANNOTATOR_PORT=9999
6477
```
6578

66-
## Hook Flow
79+
## Plan Review Flow
6780

6881
```
6982
Claude calls ExitPlanMode
@@ -80,8 +93,28 @@ Approve → stdout: {"hookSpecificOutput":{"decision":{"behavior":"allow"}}}
8093
Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","message":"..."}}}
8194
```
8295

96+
## Code Review Flow
97+
98+
```
99+
User runs /plannotator-review command
100+
101+
Claude Code: plannotator review subcommand runs
102+
OpenCode: event handler intercepts command
103+
104+
git diff captures unstaged changes
105+
106+
Review server starts, opens browser with diff viewer
107+
108+
User annotates code, provides feedback
109+
110+
Send Feedback → feedback sent to agent session
111+
Approve → "LGTM" sent to agent session
112+
```
113+
83114
## Server API
84115

116+
### Plan Server (`packages/server/index.ts`)
117+
85118
| Endpoint | Method | Purpose |
86119
| --------------------- | ------ | ------------------------------------------ |
87120
| `/api/plan` | GET | Returns `{ plan, origin }` |
@@ -91,9 +124,16 @@ Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","messag
91124
| `/api/upload` | POST | Upload image, returns temp path |
92125
| `/api/obsidian/vaults`| GET | Detect available Obsidian vaults |
93126

94-
**Location:** `packages/server/index.ts`
127+
### Review Server (`packages/server/review.ts`)
128+
129+
| Endpoint | Method | Purpose |
130+
| --------------------- | ------ | ------------------------------------------ |
131+
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin }` |
132+
| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) |
133+
| `/api/image` | GET | Serve image by path query param |
134+
| `/api/upload` | POST | Upload image, returns temp path |
95135

96-
Both plugins use `startPlannotatorServer()` from `packages/server`. Port is random locally or fixed (`19432`) in remote mode.
136+
Both servers use random ports locally or fixed port (`19432`) in remote mode.
97137

98138
## Data Types
99139

@@ -213,7 +253,8 @@ Code blocks use bundled `highlight.js`. Language is extracted from fence (```rus
213253
bun install
214254

215255
# Run any app
216-
bun run dev:hook # Hook server
256+
bun run dev:hook # Hook server (plan review)
257+
bun run dev:review # Review editor (code review)
217258
bun run dev:portal # Portal editor
218259
bun run dev:marketing # Marketing site
219260
```
@@ -222,8 +263,11 @@ bun run dev:marketing # Marketing site
222263

223264
```bash
224265
bun run build:hook # Single-file HTML for hook server
266+
bun run build:review # Code review editor
267+
bun run build:opencode # OpenCode plugin (copies HTML from hook + review)
225268
bun run build:portal # Static build for share.plannotator.ai
226269
bun run build:marketing # Static build for plannotator.ai
270+
bun run build # Build hook + opencode (main targets)
227271
```
228272

229273
## Test plugin locally
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
description: Open interactive code review for current changes
3+
allowed-tools: Bash(plannotator:*)
4+
---
5+
6+
## Code Review Feedback
7+
8+
!`plannotator review`
9+
10+
## Your task
11+
12+
Address the code review feedback above. The user has reviewed your changes in the Plannotator UI and provided specific annotations and comments.

apps/hook/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
8-
"build": "vite build && cp dist/index.html dist/redline.html",
8+
"build": "vite build && cp dist/index.html dist/redline.html && cp ../review/dist/index.html dist/review.html",
99
"serve": "bun run server/index.ts"
1010
},
1111
"dependencies": {

apps/hook/server/index.ts

Lines changed: 133 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,154 @@
11
/**
2-
* Plannotator Ephemeral Server for Claude Code
2+
* Plannotator CLI for Claude Code
33
*
4-
* Spawned by ExitPlanMode hook to serve Plannotator UI and handle approve/deny decisions.
5-
* Supports both local and remote sessions (SSH, devcontainer).
4+
* Supports two modes:
5+
*
6+
* 1. Plan Review (default, no args):
7+
* - Spawned by ExitPlanMode hook
8+
* - Reads hook event from stdin, extracts plan content
9+
* - Serves UI, returns approve/deny decision to stdout
10+
*
11+
* 2. Code Review (`plannotator review`):
12+
* - Triggered by /review slash command
13+
* - Runs git diff, opens review UI
14+
* - Outputs feedback to stdout (captured by slash command)
615
*
716
* Environment variables:
817
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (preferred)
918
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
10-
*
11-
* Reads hook event from stdin, extracts plan content, serves UI, returns decision.
1219
*/
1320

1421
import {
1522
startPlannotatorServer,
1623
handleServerReady,
1724
} from "@plannotator/server";
25+
import {
26+
startReviewServer,
27+
handleReviewServerReady,
28+
} from "@plannotator/server/review";
29+
import { getGitContext, runGitDiff } from "@plannotator/server/git";
1830

1931
// Embed the built HTML at compile time
2032
// @ts-ignore - Bun import attribute for text
21-
import indexHtml from "../dist/index.html" with { type: "text" };
22-
const htmlContent = indexHtml as unknown as string;
23-
24-
// Read hook event from stdin
25-
const eventJson = await Bun.stdin.text();
26-
27-
let planContent = "";
28-
try {
29-
const event = JSON.parse(eventJson);
30-
planContent = event.tool_input?.plan || "";
31-
} catch {
32-
console.error("Failed to parse hook event from stdin");
33-
process.exit(1);
34-
}
33+
import planHtml from "../dist/index.html" with { type: "text" };
34+
const planHtmlContent = planHtml as unknown as string;
3535

36-
if (!planContent) {
37-
console.error("No plan content in hook event");
38-
process.exit(1);
39-
}
36+
// @ts-ignore - Bun import attribute for text
37+
import reviewHtml from "../dist/review.html" with { type: "text" };
38+
const reviewHtmlContent = reviewHtml as unknown as string;
4039

41-
// Start the shared server
42-
const origin = "claude-code";
43-
44-
const server = await startPlannotatorServer({
45-
plan: planContent,
46-
origin,
47-
htmlContent,
48-
onReady: (url, isRemote, port) => {
49-
handleServerReady(url, isRemote, port);
50-
},
51-
});
52-
53-
// Wait for user decision (blocks until approve/deny)
54-
const result = await server.waitForDecision();
55-
56-
// Give browser time to receive response and update UI
57-
await Bun.sleep(1500);
58-
59-
// Cleanup
60-
server.stop();
61-
62-
// Output JSON for PermissionRequest hook decision control
63-
if (result.approved) {
64-
console.log(
65-
JSON.stringify({
66-
hookSpecificOutput: {
67-
hookEventName: "PermissionRequest",
68-
decision: {
69-
behavior: "allow",
70-
},
71-
},
72-
})
40+
// Check for subcommand
41+
const args = process.argv.slice(2);
42+
43+
if (args[0] === "review") {
44+
// ============================================
45+
// CODE REVIEW MODE
46+
// ============================================
47+
48+
// Get git context (branches, available diff options)
49+
const gitContext = await getGitContext();
50+
51+
// Run git diff HEAD (uncommitted changes - default)
52+
const { patch: rawPatch, label: gitRef } = await runGitDiff(
53+
"uncommitted",
54+
gitContext.defaultBranch
7355
);
56+
57+
if (!rawPatch.trim()) {
58+
console.log("No changes to review.");
59+
process.exit(0);
60+
}
61+
62+
// Start review server
63+
const server = await startReviewServer({
64+
rawPatch,
65+
gitRef,
66+
origin: "claude-code",
67+
diffType: "uncommitted",
68+
gitContext,
69+
htmlContent: reviewHtmlContent,
70+
onReady: handleReviewServerReady,
71+
});
72+
73+
// Wait for user feedback
74+
const result = await server.waitForDecision();
75+
76+
// Give browser time to receive response and update UI
77+
await Bun.sleep(1500);
78+
79+
// Cleanup
80+
server.stop();
81+
82+
// Output feedback (captured by slash command)
83+
console.log(result.feedback || "No feedback provided.");
84+
process.exit(0);
85+
7486
} else {
75-
console.log(
76-
JSON.stringify({
77-
hookSpecificOutput: {
78-
hookEventName: "PermissionRequest",
79-
decision: {
80-
behavior: "deny",
81-
message: result.feedback || "Plan changes requested",
87+
// ============================================
88+
// PLAN REVIEW MODE (default)
89+
// ============================================
90+
91+
// Read hook event from stdin
92+
const eventJson = await Bun.stdin.text();
93+
94+
let planContent = "";
95+
try {
96+
const event = JSON.parse(eventJson);
97+
planContent = event.tool_input?.plan || "";
98+
} catch {
99+
console.error("Failed to parse hook event from stdin");
100+
process.exit(1);
101+
}
102+
103+
if (!planContent) {
104+
console.error("No plan content in hook event");
105+
process.exit(1);
106+
}
107+
108+
// Start the plan review server
109+
const server = await startPlannotatorServer({
110+
plan: planContent,
111+
origin: "claude-code",
112+
htmlContent: planHtmlContent,
113+
onReady: (url, isRemote, port) => {
114+
handleServerReady(url, isRemote, port);
115+
},
116+
});
117+
118+
// Wait for user decision (blocks until approve/deny)
119+
const result = await server.waitForDecision();
120+
121+
// Give browser time to receive response and update UI
122+
await Bun.sleep(1500);
123+
124+
// Cleanup
125+
server.stop();
126+
127+
// Output JSON for PermissionRequest hook decision control
128+
if (result.approved) {
129+
console.log(
130+
JSON.stringify({
131+
hookSpecificOutput: {
132+
hookEventName: "PermissionRequest",
133+
decision: {
134+
behavior: "allow",
135+
},
82136
},
83-
},
84-
})
85-
);
86-
}
137+
})
138+
);
139+
} else {
140+
console.log(
141+
JSON.stringify({
142+
hookSpecificOutput: {
143+
hookEventName: "PermissionRequest",
144+
decision: {
145+
behavior: "deny",
146+
message: result.feedback || "Plan changes requested",
147+
},
148+
},
149+
})
150+
);
151+
}
87152

88-
process.exit(0);
153+
process.exit(0);
154+
}

0 commit comments

Comments
 (0)