@bugabinga/pi-ext-diff-review 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/PLAN.md +770 -0
- package/README.md +16 -0
- package/args.ts +101 -0
- package/assets/workflow_suite.gif +0 -0
- package/browser/assets/JetBrainsMonoNuguCode.LICENSE +96 -0
- package/browser/assets/JetBrainsMonoNuguCode.woff2 +0 -0
- package/browser/index.html +119 -0
- package/browser/index.ts +1184 -0
- package/browser/shadow-css.ts +179 -0
- package/browser/style.css +772 -0
- package/browser/theme.ts +49 -0
- package/bun.lock +407 -0
- package/bundle.ts +75 -0
- package/constants.ts +14 -0
- package/format.ts +74 -0
- package/git.ts +299 -0
- package/index.ts +157 -0
- package/open-browser.ts +39 -0
- package/package.json +24 -0
- package/scripts/browser-regression.ts +206 -0
- package/scripts/build-browser.ts +56 -0
- package/scripts/smoke-browser.ts +268 -0
- package/server.ts +361 -0
- package/session-state.ts +37 -0
- package/types.ts +130 -0
package/PLAN.md
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
# diff-review plan
|
|
2
|
+
|
|
3
|
+
Interactive browser review workflow for Pi using Pierre.
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Add `/diff-review`: collect a git diff, open a tokenized localhost browser UI powered by `@pierre/diffs` + `@pierre/trees`, let user annotate exact changed lines, then persist structured findings and send a user message back to Pi so the agent proposes a fix plan before editing.
|
|
8
|
+
|
|
9
|
+
## Verdict-driven constraints
|
|
10
|
+
|
|
11
|
+
These are blockers, not polish:
|
|
12
|
+
|
|
13
|
+
- browser bundle lifecycle must be enforced before command starts
|
|
14
|
+
- Pierre APIs must match installed published typings, not repo assumptions
|
|
15
|
+
- browser opener must be explicit + safe with manual fallback
|
|
16
|
+
- command must never wait forever: timeout, heartbeat, abort, cancel command, shared cleanup
|
|
17
|
+
- static server must serve complete production output dir, not only `app.js`
|
|
18
|
+
- browser smoke test must validate production bundle in real browser before v1 is considered done
|
|
19
|
+
|
|
20
|
+
## Workspace conventions from `AGENTS.md`
|
|
21
|
+
|
|
22
|
+
- New extension path is `agent/extensions/diff-review/`.
|
|
23
|
+
- Required first files:
|
|
24
|
+
1. `agent/extensions/diff-review/package.json`
|
|
25
|
+
2. `agent/extensions/diff-review/index.ts`
|
|
26
|
+
3. run `bun install` after structural/package changes
|
|
27
|
+
- `package.json` must follow workspace template: `@bugabinga/pi-ext-diff-review`, `private`, `type: "module"`, `main: "index.ts"`.
|
|
28
|
+
- Add only deps actually imported by this extension.
|
|
29
|
+
- Pi packages (`@earendil-works/*`) go in `peerDependencies`.
|
|
30
|
+
- Do not import from any `@bugabinga/pi-ext-*` extension; copy local patterns instead.
|
|
31
|
+
- TS source imports local files with `.js` specifiers, matching existing extensions.
|
|
32
|
+
- Use existing extension patterns:
|
|
33
|
+
- constants in `constants.ts` (`EXTENSION_ID`, command names, status ids)
|
|
34
|
+
- types in `types.ts`
|
|
35
|
+
- session helpers in `session-state.ts`, following `q/session-state.ts`
|
|
36
|
+
- `index.ts` stays orchestration-only where possible
|
|
37
|
+
|
|
38
|
+
## Verified research
|
|
39
|
+
|
|
40
|
+
### Pi extension APIs
|
|
41
|
+
|
|
42
|
+
- Extension entrypoint: `agent/extensions/diff-review/index.ts` exporting `default function (pi: ExtensionAPI)`.
|
|
43
|
+
- Slash command via `pi.registerCommand("diff-review", { handler })`.
|
|
44
|
+
- Command handler should:
|
|
45
|
+
- require `ctx.hasUI`
|
|
46
|
+
- `await ctx.waitForIdle()` before starting
|
|
47
|
+
- block until browser submit/cancel/timeout/abort
|
|
48
|
+
- call `pi.sendUserMessage([{ type: "text", text: markdown }])` after submit
|
|
49
|
+
- Persist branch-local state via custom session entries:
|
|
50
|
+
- use `pi.appendEntry(customType, data)` when entry id is not needed
|
|
51
|
+
- use local helper with `ExtensionContext["sessionManager"]` cast when entry id is needed, matching `q/session-state.ts`
|
|
52
|
+
- Restore state on `session_start` by scanning `ctx.sessionManager.getBranch()` and selecting entries where:
|
|
53
|
+
- `entry.type === "custom"`
|
|
54
|
+
- `entry.customType === "diff-review"`
|
|
55
|
+
- Clean up active localhost server on `session_shutdown`.
|
|
56
|
+
- No verified generic Pi URL-open API found. Implement platform opener fallback in extension.
|
|
57
|
+
|
|
58
|
+
### Verified `@pierre/diffs@1.1.22` typings
|
|
59
|
+
|
|
60
|
+
Installed package inspected from npm tarball under `~/.pi/research/extracts/pierre-npm/diffs`.
|
|
61
|
+
|
|
62
|
+
Relevant exact API:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import {
|
|
66
|
+
FileDiff,
|
|
67
|
+
parsePatchFiles,
|
|
68
|
+
type DiffLineAnnotation,
|
|
69
|
+
type FileDiffMetadata,
|
|
70
|
+
type ParsedPatch,
|
|
71
|
+
type SelectedLineRange,
|
|
72
|
+
} from "@pierre/diffs";
|
|
73
|
+
|
|
74
|
+
const patches: ParsedPatch[] = parsePatchFiles(diffText, "diff-review", true);
|
|
75
|
+
|
|
76
|
+
const instance = new FileDiff<{ findingId: string }>({
|
|
77
|
+
theme: { dark: "pierre-dark", light: "pierre-light" },
|
|
78
|
+
diffStyle: "split",
|
|
79
|
+
overflow: "scroll",
|
|
80
|
+
enableLineSelection: true,
|
|
81
|
+
enableGutterUtility: true,
|
|
82
|
+
lineHoverHighlight: "both",
|
|
83
|
+
onLineSelected(range: SelectedLineRange | null) {},
|
|
84
|
+
onGutterUtilityClick(range: SelectedLineRange) {},
|
|
85
|
+
renderAnnotation(annotation: DiffLineAnnotation<{ findingId: string }>) {
|
|
86
|
+
return document.createElement("div");
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const ok: boolean = instance.render({
|
|
91
|
+
fileDiff,
|
|
92
|
+
containerWrapper,
|
|
93
|
+
lineAnnotations,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
instance.setLineAnnotations(lineAnnotations);
|
|
97
|
+
instance.setSelectedLines({ start, end, side: "additions", endSide: "additions" });
|
|
98
|
+
instance.rerender();
|
|
99
|
+
instance.cleanUp();
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`FileDiff.render(...)` accepts:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
{
|
|
106
|
+
fileDiff?: FileDiffMetadata;
|
|
107
|
+
oldFile?: FileContents;
|
|
108
|
+
newFile?: FileContents;
|
|
109
|
+
forceRender?: boolean;
|
|
110
|
+
preventEmit?: boolean;
|
|
111
|
+
fileContainer?: HTMLElement;
|
|
112
|
+
containerWrapper?: HTMLElement;
|
|
113
|
+
lineAnnotations?: DiffLineAnnotation<T>[];
|
|
114
|
+
renderRange?: RenderRange;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`SelectedLineRange` is:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
{
|
|
122
|
+
start: number;
|
|
123
|
+
side?: "deletions" | "additions";
|
|
124
|
+
end: number;
|
|
125
|
+
endSide?: "deletions" | "additions";
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`DiffLineAnnotation<T>` is:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
{
|
|
133
|
+
side: "deletions" | "additions";
|
|
134
|
+
lineNumber: number;
|
|
135
|
+
metadata: T;
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Important asset note:
|
|
140
|
+
|
|
141
|
+
- `@pierre/diffs` ships `dist/style.js`; component code imports CSS through wrapper utilities/web component registration.
|
|
142
|
+
- Worker assets exist under `dist/worker/*`, but v1 will not instantiate `WorkerPoolManager`; no worker route needed unless later enabled.
|
|
143
|
+
|
|
144
|
+
### Verified `@pierre/trees@1.0.0-beta.4` typings
|
|
145
|
+
|
|
146
|
+
Installed package inspected from npm tarball under `~/.pi/research/extracts/pierre-npm/trees`.
|
|
147
|
+
|
|
148
|
+
Relevant exact API:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { FileTree, type GitStatusEntry } from "@pierre/trees";
|
|
152
|
+
|
|
153
|
+
const tree = new FileTree({
|
|
154
|
+
paths,
|
|
155
|
+
search: true,
|
|
156
|
+
initialExpansion: "open",
|
|
157
|
+
initialSelectedPaths: [],
|
|
158
|
+
gitStatus,
|
|
159
|
+
onSelectionChange(selectedPaths: readonly string[]) {},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
tree.render({ fileTreeContainer });
|
|
163
|
+
// or tree.render({ containerWrapper });
|
|
164
|
+
|
|
165
|
+
tree.getSelectedPaths();
|
|
166
|
+
tree.getFocusedPath();
|
|
167
|
+
tree.focusPath(path);
|
|
168
|
+
tree.scrollToPath(path);
|
|
169
|
+
tree.setGitStatus(gitStatus);
|
|
170
|
+
tree.cleanUp();
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`FileTree` render props are exactly:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
{
|
|
177
|
+
containerWrapper?: HTMLElement;
|
|
178
|
+
fileTreeContainer?: HTMLElement;
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Scope
|
|
183
|
+
|
|
184
|
+
### Command
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
/diff-review [--base origin/main] [--staged] [--files a.ts,b.ts]
|
|
188
|
+
/diff-review-cancel
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Behavior:
|
|
192
|
+
|
|
193
|
+
- interactive-only
|
|
194
|
+
- waits for browser submit/cancel/timeout
|
|
195
|
+
- on submit:
|
|
196
|
+
1. append `diff-review` custom entry
|
|
197
|
+
2. shared cleanup
|
|
198
|
+
3. send markdown review to Pi as user message
|
|
199
|
+
- on browser/user cancel: shared cleanup, no session entry, no user message
|
|
200
|
+
- `/diff-review-cancel`: abort active review server and unblock command
|
|
201
|
+
- starting a second `/diff-review` while one is active: notify error with active URL
|
|
202
|
+
|
|
203
|
+
Flag semantics:
|
|
204
|
+
|
|
205
|
+
- default: unstaged working tree diff (`git diff`), untracked files ignored in v1
|
|
206
|
+
- `--staged`: staged diff (`git diff --cached`)
|
|
207
|
+
- `--base <ref>`: branch/base comparison (`git diff <ref>...HEAD`)
|
|
208
|
+
- `--staged` and `--base` are mutually exclusive
|
|
209
|
+
- `--files a.ts,b.ts`: path filter, relative paths only, no absolute paths, no `..`
|
|
210
|
+
|
|
211
|
+
### Git commands
|
|
212
|
+
|
|
213
|
+
Run with `execFile`/`pi.exec` args, never shell interpolation.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
// repo check/root
|
|
217
|
+
git rev-parse --show-toplevel
|
|
218
|
+
|
|
219
|
+
// default
|
|
220
|
+
git diff --no-ext-diff --find-renames --unified=3 -- [files...]
|
|
221
|
+
|
|
222
|
+
// staged
|
|
223
|
+
git diff --cached --no-ext-diff --find-renames --unified=3 -- [files...]
|
|
224
|
+
|
|
225
|
+
// base
|
|
226
|
+
git diff --no-ext-diff --find-renames --unified=3 <base>...HEAD -- [files...]
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Empty diff → notify `No diff to review` and return.
|
|
230
|
+
|
|
231
|
+
Binary-only/unparseable files:
|
|
232
|
+
|
|
233
|
+
- keep raw diff in payload
|
|
234
|
+
- mark file as skipped if no hunks can be rendered
|
|
235
|
+
- do not allow line findings for skipped files
|
|
236
|
+
|
|
237
|
+
## Browser bundle lifecycle
|
|
238
|
+
|
|
239
|
+
### Chosen policy
|
|
240
|
+
|
|
241
|
+
Commit generated production browser assets and also guard against stale/missing builds at runtime.
|
|
242
|
+
|
|
243
|
+
Implementation layout:
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
agent/extensions/diff-review/
|
|
247
|
+
├── package.json
|
|
248
|
+
├── index.ts # register commands/events; orchestration only
|
|
249
|
+
├── constants.ts # EXTENSION_ID, command/status ids, defaults
|
|
250
|
+
├── types.ts # shared node/browser serializable types
|
|
251
|
+
├── args.ts # slash-arg parser
|
|
252
|
+
├── git.ts # git diff collection + DiffIndex
|
|
253
|
+
├── server.ts # localhost server + token auth + lifecycle
|
|
254
|
+
├── session-state.ts # append/restore helpers, q-style
|
|
255
|
+
├── format.ts # markdown formatting
|
|
256
|
+
├── open-browser.ts # safe opener fallback
|
|
257
|
+
├── scripts/
|
|
258
|
+
│ ├── build-browser.ts
|
|
259
|
+
│ └── smoke-browser.ts
|
|
260
|
+
├── browser/
|
|
261
|
+
│ ├── index.html
|
|
262
|
+
│ ├── index.ts
|
|
263
|
+
│ └── style.css
|
|
264
|
+
└── static/
|
|
265
|
+
├── index.html
|
|
266
|
+
├── app.js
|
|
267
|
+
├── app.css? # if Bun emits/copies one
|
|
268
|
+
├── assets/... # any emitted assets
|
|
269
|
+
└── manifest.json # generated by build script
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Pattern rules:
|
|
273
|
+
|
|
274
|
+
- Node files import Pi types from `@earendil-works/pi-coding-agent`.
|
|
275
|
+
- Browser files import Pierre deps only; keep Pi SDK out of browser bundle.
|
|
276
|
+
- Local imports use `.js` specifiers, e.g. `import { EXTENSION_ID } from "./constants.js"`.
|
|
277
|
+
- No imports from sibling extensions.
|
|
278
|
+
|
|
279
|
+
`static/` is generated but committed for normal Pi runtime. Server serves all of `static/`, not individual hard-coded assets.
|
|
280
|
+
|
|
281
|
+
### Build script requirements
|
|
282
|
+
|
|
283
|
+
Use a small `scripts/build-browser.ts`, not only raw `bun build`, because it must:
|
|
284
|
+
|
|
285
|
+
1. copy `browser/index.html` → `static/index.html`
|
|
286
|
+
2. bundle `browser/index.ts` → `static/app.js`
|
|
287
|
+
3. copy/emit any CSS/assets Bun produces
|
|
288
|
+
4. write `static/manifest.json` containing:
|
|
289
|
+
- source file mtimes + sha256 for `browser/**`
|
|
290
|
+
- package versions for `@pierre/diffs` and `@pierre/trees`
|
|
291
|
+
- build timestamp
|
|
292
|
+
- Bun version
|
|
293
|
+
|
|
294
|
+
Runtime `ensureBrowserBundle()`:
|
|
295
|
+
|
|
296
|
+
- if `static/index.html`, `static/app.js`, or manifest missing:
|
|
297
|
+
- try auto-build if `bun` is available and extension dir is writable
|
|
298
|
+
- otherwise fail fast with exact command: `cd agent/extensions/diff-review && bun install && bun run build`
|
|
299
|
+
- if manifest source hashes/mtimes do not match:
|
|
300
|
+
- try auto-build in dev workspace
|
|
301
|
+
- otherwise fail fast with same command
|
|
302
|
+
- if auto-build fails: show stderr + command, do not start server
|
|
303
|
+
|
|
304
|
+
### Package scaffold
|
|
305
|
+
|
|
306
|
+
Start from `AGENTS.md` template, then add only imported deps:
|
|
307
|
+
|
|
308
|
+
```json
|
|
309
|
+
{
|
|
310
|
+
"name": "@bugabinga/pi-ext-diff-review",
|
|
311
|
+
"version": "0.0.0",
|
|
312
|
+
"private": true,
|
|
313
|
+
"type": "module",
|
|
314
|
+
"main": "index.ts",
|
|
315
|
+
"scripts": {
|
|
316
|
+
"build": "bun ./scripts/build-browser.ts",
|
|
317
|
+
"smoke:browser": "bun ./scripts/smoke-browser.ts"
|
|
318
|
+
},
|
|
319
|
+
"dependencies": {
|
|
320
|
+
"@pierre/diffs": "^1.1.22",
|
|
321
|
+
"@pierre/trees": "^1.0.0-beta.4"
|
|
322
|
+
},
|
|
323
|
+
"peerDependencies": {
|
|
324
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Notes:
|
|
330
|
+
|
|
331
|
+
- `@pierre/diffs` and `@pierre/trees` are `dependencies` because `browser/index.ts` imports them.
|
|
332
|
+
- Do not add `@earendil-works/pi-tui` unless Node-side rendering/widget code imports it.
|
|
333
|
+
- Do not add Playwright unless `scripts/smoke-browser.ts` imports it; if used, add as `devDependencies` only.
|
|
334
|
+
- After creating/changing this package file or build layout, run `bun install` from workspace/extension as required by `AGENTS.md`.
|
|
335
|
+
|
|
336
|
+
Run after scaffold/deps/asset changes:
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
cd agent/extensions/diff-review
|
|
340
|
+
bun install
|
|
341
|
+
bun run build
|
|
342
|
+
bun run smoke:browser
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Browser launch strategy
|
|
346
|
+
|
|
347
|
+
No Pi URL-open API is verified, so use safe platform fallback.
|
|
348
|
+
|
|
349
|
+
`openBrowser(url)`:
|
|
350
|
+
|
|
351
|
+
- Termux: `termux-open-url <url>` if available
|
|
352
|
+
- macOS: `open <url>`
|
|
353
|
+
- Windows: `cmd.exe /c start "" <url>`
|
|
354
|
+
- Linux/BSD: `xdg-open <url>`
|
|
355
|
+
- WSL fallback if needed: `wslview <url>`
|
|
356
|
+
|
|
357
|
+
Rules:
|
|
358
|
+
|
|
359
|
+
- use `spawn`/`execFile`, no shell except Windows `cmd.exe /c start`
|
|
360
|
+
- opener failure is non-fatal
|
|
361
|
+
- always show manual URL via `ctx.ui.notify` and status/widget
|
|
362
|
+
- optionally copy URL if a clipboard command exists (`wl-copy`, `xclip`, `pbcopy`, `clip.exe`, `termux-clipboard-set`), but do not depend on clipboard
|
|
363
|
+
|
|
364
|
+
UI status while active:
|
|
365
|
+
|
|
366
|
+
```text
|
|
367
|
+
diff-review: open http://127.0.0.1:<port>/?token=...
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Abort / timeout / heartbeat
|
|
371
|
+
|
|
372
|
+
All command exits go through one `finish(result)` path guarded by an idempotent state flag.
|
|
373
|
+
|
|
374
|
+
Active review state:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
type ActiveReview = {
|
|
378
|
+
id: string;
|
|
379
|
+
url: string;
|
|
380
|
+
server: Server;
|
|
381
|
+
controller: AbortController;
|
|
382
|
+
heartbeatTimer: NodeJS.Timeout;
|
|
383
|
+
timeoutTimer: NodeJS.Timeout;
|
|
384
|
+
lastHeartbeatAt: number;
|
|
385
|
+
resolve(result: BrowserResult): void;
|
|
386
|
+
};
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Timeouts:
|
|
390
|
+
|
|
391
|
+
- hard session timeout default: 30 min
|
|
392
|
+
- heartbeat interval from browser: 5s
|
|
393
|
+
- heartbeat considered lost after 30s
|
|
394
|
+
- heartbeat loss only warns first; hard timeout or explicit cancel ends command
|
|
395
|
+
- `/diff-review-cancel` ends immediately
|
|
396
|
+
- `session_shutdown` aborts active review immediately
|
|
397
|
+
|
|
398
|
+
Browser routes:
|
|
399
|
+
|
|
400
|
+
```text
|
|
401
|
+
POST /api/heartbeat updates lastHeartbeatAt
|
|
402
|
+
POST /api/cancel explicit browser cancel
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
If browser tab closes, heartbeat warning tells user to reopen URL or run `/diff-review-cancel`; hard timeout prevents indefinite hang.
|
|
406
|
+
|
|
407
|
+
## Local server protocol
|
|
408
|
+
|
|
409
|
+
Bind only to localhost:
|
|
410
|
+
|
|
411
|
+
```text
|
|
412
|
+
127.0.0.1:0
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Token:
|
|
416
|
+
|
|
417
|
+
- `crypto.randomBytes(32).toString("base64url")`
|
|
418
|
+
- browser URL: `http://127.0.0.1:<port>/?token=<token>`
|
|
419
|
+
- all `/api/*` requests require `Authorization: Bearer <token>`
|
|
420
|
+
- static files require either token query on `/` or a same-origin session cookie set by `/`; API still uses bearer token
|
|
421
|
+
|
|
422
|
+
Routes:
|
|
423
|
+
|
|
424
|
+
```text
|
|
425
|
+
GET / static/index.html; requires token query
|
|
426
|
+
GET /static/* files from static build dir
|
|
427
|
+
GET /api/review JSON review payload
|
|
428
|
+
POST /api/heartbeat heartbeat
|
|
429
|
+
POST /api/finding/validate validate selection + preview anchor
|
|
430
|
+
POST /api/submit submit notes/findings
|
|
431
|
+
POST /api/cancel cancel command
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
`GET /api/review` response:
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
type ReviewPayload = {
|
|
438
|
+
round: number;
|
|
439
|
+
source: ReviewSource;
|
|
440
|
+
diffText: string;
|
|
441
|
+
files: Array<{
|
|
442
|
+
path: string;
|
|
443
|
+
prevPath?: string;
|
|
444
|
+
status: "added" | "modified" | "deleted" | "renamed" | "binary";
|
|
445
|
+
additions: number;
|
|
446
|
+
deletions: number;
|
|
447
|
+
}>;
|
|
448
|
+
carriedFindings: Finding[];
|
|
449
|
+
};
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Command awaits one of:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
type BrowserResult =
|
|
456
|
+
| { type: "submit"; notes?: string; findings: FindingDraft[] }
|
|
457
|
+
| { type: "cancel"; reason: "browser" | "command" | "shutdown" | "timeout" };
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Cleanup:
|
|
461
|
+
|
|
462
|
+
- close server
|
|
463
|
+
- abort controller
|
|
464
|
+
- clear timers
|
|
465
|
+
- clear status/widget
|
|
466
|
+
- clean active review global
|
|
467
|
+
|
|
468
|
+
## Browser UI
|
|
469
|
+
|
|
470
|
+
Layout:
|
|
471
|
+
|
|
472
|
+
- left sidebar: `@pierre/trees` changed-file tree
|
|
473
|
+
- main panel: one `@pierre/diffs` `FileDiff` per changed file
|
|
474
|
+
- right drawer: active selection + annotation form
|
|
475
|
+
- footer/header: submit/cancel, round number, source summary
|
|
476
|
+
|
|
477
|
+
Review drawer fields:
|
|
478
|
+
|
|
479
|
+
- category: `bug | style | perf | question`
|
|
480
|
+
- severity: `high | medium | low`
|
|
481
|
+
- comment: required
|
|
482
|
+
- notes: optional per-finding note or global notes textarea
|
|
483
|
+
|
|
484
|
+
Selection rules:
|
|
485
|
+
|
|
486
|
+
- use `onLineSelected(range)` and `onGutterUtilityClick(range)`
|
|
487
|
+
- accept only `range.side`/`range.endSide` in `additions | deletions`
|
|
488
|
+
- if mixed sides, reject for v1 with UI hint
|
|
489
|
+
- `startLine = Math.min(range.start, range.end)`
|
|
490
|
+
- `endLine = Math.max(range.start, range.end)`
|
|
491
|
+
- render saved findings as `DiffLineAnnotation<{ findingId, severity, category, comment }>`
|
|
492
|
+
|
|
493
|
+
Browser lifecycle:
|
|
494
|
+
|
|
495
|
+
- heartbeat starts after successful `/api/review`
|
|
496
|
+
- `beforeunload` tries best-effort `navigator.sendBeacon("/api/cancel")` only if no submit happened; command does not rely on this
|
|
497
|
+
- explicit Cancel button posts `/api/cancel`
|
|
498
|
+
- Submit disables form, posts `/api/submit`, then shows `Submitted — return to Pi`
|
|
499
|
+
|
|
500
|
+
Implementation sketch with verified APIs:
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
import { FileDiff, parsePatchFiles, type SelectedLineRange } from "@pierre/diffs";
|
|
504
|
+
import { FileTree } from "@pierre/trees";
|
|
505
|
+
|
|
506
|
+
const review = await apiGetReview();
|
|
507
|
+
const patches = parsePatchFiles(review.diffText, `diff-review-${review.round}`, true);
|
|
508
|
+
|
|
509
|
+
const tree = new FileTree({
|
|
510
|
+
paths: review.files.map((f) => f.path),
|
|
511
|
+
search: true,
|
|
512
|
+
initialExpansion: "open",
|
|
513
|
+
gitStatus: toGitStatus(review.files),
|
|
514
|
+
onSelectionChange(paths) {
|
|
515
|
+
const path = paths[0];
|
|
516
|
+
if (path) scrollToDiff(path);
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
tree.render({ fileTreeContainer: document.getElementById("tree")! });
|
|
520
|
+
|
|
521
|
+
for (const patch of patches) {
|
|
522
|
+
for (const fileDiff of patch.files) {
|
|
523
|
+
const instance = new FileDiff({
|
|
524
|
+
theme: { dark: "pierre-dark", light: "pierre-light" },
|
|
525
|
+
diffStyle: "split",
|
|
526
|
+
overflow: "scroll",
|
|
527
|
+
enableLineSelection: true,
|
|
528
|
+
enableGutterUtility: true,
|
|
529
|
+
lineHoverHighlight: "both",
|
|
530
|
+
onLineSelected: (range: SelectedLineRange | null) => openDrawer(fileDiff.name, range),
|
|
531
|
+
onGutterUtilityClick: (range: SelectedLineRange) => openDrawer(fileDiff.name, range),
|
|
532
|
+
renderAnnotation: renderFindingAnnotation,
|
|
533
|
+
});
|
|
534
|
+
instance.render({ fileDiff, containerWrapper: diffRoot, lineAnnotations: [] });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## Agent result
|
|
540
|
+
|
|
541
|
+
After submit, send this as a user message:
|
|
542
|
+
|
|
543
|
+
```md
|
|
544
|
+
# Diff Review Round N
|
|
545
|
+
|
|
546
|
+
## Findings
|
|
547
|
+
- [high][bug] src/foo.ts:43 additions — comment
|
|
548
|
+
|
|
549
|
+
## Notes
|
|
550
|
+
...
|
|
551
|
+
|
|
552
|
+
Use this review to propose and discuss a fix plan first. Do not edit files yet.
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
No findings:
|
|
556
|
+
|
|
557
|
+
```md
|
|
558
|
+
# Diff Review Round N
|
|
559
|
+
|
|
560
|
+
No findings recorded.
|
|
561
|
+
|
|
562
|
+
Use this review result to decide whether any follow-up is needed. Do not edit files yet.
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
## Data model
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
type ReviewCategory = "bug" | "style" | "perf" | "question";
|
|
569
|
+
type ReviewSeverity = "high" | "medium" | "low";
|
|
570
|
+
type ReviewSide = "additions" | "deletions";
|
|
571
|
+
type FindingStatus = "open" | "resolved" | "closed_auto";
|
|
572
|
+
|
|
573
|
+
type Finding = {
|
|
574
|
+
id: string;
|
|
575
|
+
round: number;
|
|
576
|
+
file: string;
|
|
577
|
+
prevFile?: string;
|
|
578
|
+
side: ReviewSide;
|
|
579
|
+
startLine: number;
|
|
580
|
+
endLine: number;
|
|
581
|
+
category: ReviewCategory;
|
|
582
|
+
severity: ReviewSeverity;
|
|
583
|
+
comment: string;
|
|
584
|
+
notes?: string;
|
|
585
|
+
status: FindingStatus;
|
|
586
|
+
statusReason?: "file_removed" | "anchor_missing" | "manual";
|
|
587
|
+
anchor: {
|
|
588
|
+
before: string;
|
|
589
|
+
selected: string;
|
|
590
|
+
after: string;
|
|
591
|
+
};
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
type ReviewSource = {
|
|
595
|
+
kind: "working-tree" | "staged" | "base";
|
|
596
|
+
base?: string;
|
|
597
|
+
files?: string[];
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
type ReviewRoundEntry = {
|
|
601
|
+
version: 1;
|
|
602
|
+
kind: "round";
|
|
603
|
+
round: number;
|
|
604
|
+
source: ReviewSource;
|
|
605
|
+
findings: Finding[];
|
|
606
|
+
notes?: string;
|
|
607
|
+
diffSummary: {
|
|
608
|
+
filesChanged: number;
|
|
609
|
+
additions: number;
|
|
610
|
+
deletions: number;
|
|
611
|
+
skippedFiles: string[];
|
|
612
|
+
};
|
|
613
|
+
createdAt: string;
|
|
614
|
+
};
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Custom session entry:
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
pi.appendEntry("diff-review", reviewRoundEntry);
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Restore state:
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
function restore(branch: SessionEntry[]): ReviewState {
|
|
627
|
+
const rounds: ReviewRoundEntry[] = [];
|
|
628
|
+
for (const entry of branch) {
|
|
629
|
+
if (entry.type !== "custom" || entry.customType !== "diff-review") continue;
|
|
630
|
+
const data = entry.data as ReviewRoundEntry;
|
|
631
|
+
if (data?.version === 1 && data.kind === "round") rounds.push(data);
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
round: Math.max(0, ...rounds.map((r) => r.round)),
|
|
635
|
+
findings: rounds.flatMap((r) => r.findings),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
## Diff index / anchors
|
|
641
|
+
|
|
642
|
+
Server builds a `DiffIndex` from unified patch before opening browser:
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
type DiffLineMap = Map<number, string>;
|
|
646
|
+
|
|
647
|
+
type IndexedFileDiff = {
|
|
648
|
+
file: string;
|
|
649
|
+
prevFile?: string;
|
|
650
|
+
status: "added" | "modified" | "deleted" | "renamed" | "binary";
|
|
651
|
+
additions: DiffLineMap; // new-file line number → text
|
|
652
|
+
deletions: DiffLineMap; // old-file line number → text
|
|
653
|
+
};
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Anchor generation on submit:
|
|
657
|
+
|
|
658
|
+
- browser sends selection + form data only
|
|
659
|
+
- server looks up selected lines in `DiffIndex`
|
|
660
|
+
- `selected`: selected line texts joined with `\n`
|
|
661
|
+
- `before`: up to 3 available same-side lines before `startLine`
|
|
662
|
+
- `after`: up to 3 available same-side lines after `endLine`
|
|
663
|
+
|
|
664
|
+
If lookup fails, reject submit with `400` so browser can show stale-selection error.
|
|
665
|
+
|
|
666
|
+
## Security
|
|
667
|
+
|
|
668
|
+
- bind `127.0.0.1` only
|
|
669
|
+
- random token, required for API writes
|
|
670
|
+
- no filesystem writes from browser
|
|
671
|
+
- sanitize/validate file filters before passing to git
|
|
672
|
+
- never invoke shell with interpolated args
|
|
673
|
+
- render user comments/paths via `textContent`
|
|
674
|
+
- cap diff size for v1, e.g. 5 MiB patch text; notify if too large
|
|
675
|
+
- clear token/session state on submit/cancel/timeout
|
|
676
|
+
|
|
677
|
+
## Production browser smoke test
|
|
678
|
+
|
|
679
|
+
Add `scripts/smoke-browser.ts` before v1 completion.
|
|
680
|
+
|
|
681
|
+
Test must:
|
|
682
|
+
|
|
683
|
+
1. run `bun run build`
|
|
684
|
+
2. start extension static server with sample diff fixture
|
|
685
|
+
3. open real browser using Playwright or available Chromium
|
|
686
|
+
4. load tokenized URL
|
|
687
|
+
5. assert no 404s for `static/*`
|
|
688
|
+
6. assert no console errors
|
|
689
|
+
7. assert Pierre tree renders file path
|
|
690
|
+
8. assert Pierre diff renders changed line
|
|
691
|
+
9. create one finding via DOM event/click path if possible, otherwise call UI fn behind test hook
|
|
692
|
+
10. submit and assert server receives finding
|
|
693
|
+
|
|
694
|
+
If Playwright browser unavailable, smoke script exits with clear skip only in dev; CI/v1 release requires pass.
|
|
695
|
+
|
|
696
|
+
## Implementation phases
|
|
697
|
+
|
|
698
|
+
### 0. Preflight proof
|
|
699
|
+
|
|
700
|
+
- scaffold package
|
|
701
|
+
- install deps
|
|
702
|
+
- inspect local `node_modules/@pierre/*/dist/*.d.ts` after `bun install`
|
|
703
|
+
- build minimal browser bundle
|
|
704
|
+
- run smoke server/page manually or via `smoke:browser`
|
|
705
|
+
- verify no missing assets/console errors
|
|
706
|
+
|
|
707
|
+
Acceptance:
|
|
708
|
+
|
|
709
|
+
- `bun run build` creates `static/index.html`, `static/app.js`, `manifest.json`
|
|
710
|
+
- `bun run smoke:browser` passes or gives actionable missing-browser skip
|
|
711
|
+
|
|
712
|
+
### 1. Minimal command + local server
|
|
713
|
+
|
|
714
|
+
- parse args
|
|
715
|
+
- `ensureBrowserBundle()`
|
|
716
|
+
- collect git diff + file summary + `DiffIndex`
|
|
717
|
+
- start tokenized localhost server serving full `static/` dir
|
|
718
|
+
- open browser or show manual URL
|
|
719
|
+
- wait for submit/cancel/timeout
|
|
720
|
+
- append `diff-review` entry
|
|
721
|
+
- send markdown user message
|
|
722
|
+
- `/diff-review-cancel`
|
|
723
|
+
|
|
724
|
+
Acceptance:
|
|
725
|
+
|
|
726
|
+
- `/diff-review` opens browser for unstaged diff
|
|
727
|
+
- missing/stale browser bundle auto-builds or fails with exact command
|
|
728
|
+
- opener failure still provides usable URL
|
|
729
|
+
- Submit returns to Pi and triggers agent turn with review markdown
|
|
730
|
+
- Cancel/timeout/session shutdown unblocks command and closes server
|
|
731
|
+
|
|
732
|
+
### 2. Pierre UI
|
|
733
|
+
|
|
734
|
+
- `@pierre/trees` changed-file sidebar with verified `FileTree` API
|
|
735
|
+
- `@pierre/diffs` split diff list with verified `FileDiff` API
|
|
736
|
+
- line/range selection
|
|
737
|
+
- annotation drawer
|
|
738
|
+
- finding chips via `lineAnnotations`
|
|
739
|
+
- tree status badges via `gitStatus`
|
|
740
|
+
|
|
741
|
+
### 3. Pi persistence
|
|
742
|
+
|
|
743
|
+
- append `diff-review` entries
|
|
744
|
+
- reconstruct current-branch state on `session_start`
|
|
745
|
+
- widget/status: `N open review findings`
|
|
746
|
+
|
|
747
|
+
### 4. Reconciliation
|
|
748
|
+
|
|
749
|
+
On next review round:
|
|
750
|
+
|
|
751
|
+
- file missing → `closed_auto`, `statusReason: "file_removed"`
|
|
752
|
+
- selected anchor missing → `closed_auto`, `statusReason: "anchor_missing"`
|
|
753
|
+
- selected anchor found elsewhere → update line range
|
|
754
|
+
- user can manually resolve carried findings
|
|
755
|
+
|
|
756
|
+
### 5. Polish
|
|
757
|
+
|
|
758
|
+
- dark/light sync with Pi theme if possible
|
|
759
|
+
- markdown export fallback only if later requested
|
|
760
|
+
- optional prompt injection of open findings before next agent turn
|
|
761
|
+
- optional worker pool for large diffs, only after serving/copying worker assets is verified
|
|
762
|
+
|
|
763
|
+
## Non-goals for v1
|
|
764
|
+
|
|
765
|
+
- applying fixes directly from browser
|
|
766
|
+
- replacing normal `edit` rendering
|
|
767
|
+
- full Pi session tree browser
|
|
768
|
+
- untracked-file review
|
|
769
|
+
- non-interactive mode
|
|
770
|
+
- Pierre worker pool / WASM highlighter routing
|