@erichll/pi-auto-review 0.8.2 → 0.10.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 +39 -0
- package/README.md +16 -9
- package/package.json +3 -3
- package/src/index.ts +197 -140
- package/src/user-feedback.ts +291 -57
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 - 2026-08-22
|
|
4
|
+
|
|
5
|
+
- Replace the review footer and newly appended transcript entries with one
|
|
6
|
+
above-editor `pi-auto-review` widget. Each permission check independently
|
|
7
|
+
replaces the previous display, shows the configured reviewer model while it
|
|
8
|
+
runs, and updates in place with its final outcome and metadata. The latest
|
|
9
|
+
result remains until the next check and is cleared on session change or
|
|
10
|
+
shutdown.
|
|
11
|
+
- Prevent late concurrent completions and permission decisions from overwriting
|
|
12
|
+
a newer check. A current local denial adds `Local confirmation · denied` to
|
|
13
|
+
that result only. Widget failures fall back to notifications without changing
|
|
14
|
+
authorization behavior.
|
|
15
|
+
- Remove tool-call grouping, timers, capacity eviction, and tool/turn flush
|
|
16
|
+
wiring for new feedback while retaining the historical entry renderer. Bound
|
|
17
|
+
target and rationale text with visible ellipses, preserve width-aware wrapping,
|
|
18
|
+
and keep token, duration, and call-count numbers in the normal metadata color.
|
|
19
|
+
|
|
20
|
+
## 0.9.0 - 2026-08-22
|
|
21
|
+
|
|
22
|
+
- Require `@gotgenes/pi-permission-system` `^27.0.0` and register through the
|
|
23
|
+
session-keyed `getPermissionsService(sessionId)` locator. Repeated ready
|
|
24
|
+
broadcasts are idempotent; session changes, shutdown, and late asynchronous
|
|
25
|
+
imports cannot retain or revive a stale authorizer registration. In-process
|
|
26
|
+
child nodes register against their own service without replacing the
|
|
27
|
+
parent's process-local broker capability.
|
|
28
|
+
- Group local TUI review results by `(sessionId, toolCallId)` into one
|
|
29
|
+
transcript entry while preserving independent asks, model calls, verdicts,
|
|
30
|
+
grants, confirmations, and audit records. Denials flush immediately; tool
|
|
31
|
+
start, terminal permission denial, turn/agent/session end, a 60-second TTL,
|
|
32
|
+
and bounded-capacity eviction provide lossless fallback flushes. Forwarded,
|
|
33
|
+
uncorrelated, and non-TUI reviews remain immediate.
|
|
34
|
+
- Add `toolCallId` to each `pi_auto_review_decision` audit entry and retain
|
|
35
|
+
compatibility rendering for historical single-result transcript entries.
|
|
36
|
+
- Remove the review entry's left quote bar and combine each target with its
|
|
37
|
+
rationale and member surface/outcome on one line, keeping model/token/duration
|
|
38
|
+
metadata separate.
|
|
39
|
+
- Label every displayed model token count with `toks` in both grouped and
|
|
40
|
+
single-result output.
|
|
41
|
+
|
|
3
42
|
## 0.8.2 - 2026-08-21
|
|
4
43
|
|
|
5
44
|
- Structure interactive review results as headline, target, rationale, and a
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ that dependency is a hard prerequisite (see [Install and enable](#install-and-en
|
|
|
16
16
|
> **Prerequisite:** pi-auto-review is an authorizer inside
|
|
17
17
|
> `@gotgenes/pi-permission-system`. Pi does not auto-install peer packages, so
|
|
18
18
|
> install the permission system separately (once per machine) before this
|
|
19
|
-
> extension:
|
|
19
|
+
> extension. Version 0.10.0 requires permission-system 27.x:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
pi install npm:@gotgenes/pi-permission-system
|
|
@@ -49,7 +49,7 @@ adapters must consume the exact grant before retrying an operation. Changing
|
|
|
49
49
|
the command, path, resolved path, destination, cwd, agent, or tool input
|
|
50
50
|
invalidates that grant.
|
|
51
51
|
|
|
52
|
-
Permission-system
|
|
52
|
+
Permission-system downgrades authorizer allows on `path` and
|
|
53
53
|
`external_directory` to `defer`. In an interactive TUI,
|
|
54
54
|
`autoConfirmBoundedAllows` can bind the exact model allow to the immediately
|
|
55
55
|
following recognized permission dialog. The bridge is request-ID-bound,
|
|
@@ -132,13 +132,20 @@ only a fixed, budget-checked schema correction.
|
|
|
132
132
|
|
|
133
133
|
## Operator feedback and exact retry
|
|
134
134
|
|
|
135
|
-
Interactive sessions show
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
Interactive sessions show the current permission check in a single widget
|
|
136
|
+
above the editor. Each check first shows its surface, compact target, and the
|
|
137
|
+
dynamically configured reviewer model, then replaces that content in place
|
|
138
|
+
with the outcome, target and rationale, model, token usage, duration, and any
|
|
139
|
+
extra call count. A new check replaces the previous result; the latest result
|
|
140
|
+
remains visible until then and is cleared when the session changes or shuts
|
|
141
|
+
down. Concurrent older checks cannot overwrite the most recently started one.
|
|
142
|
+
|
|
143
|
+
Every request still has its own model call, verdict, grant, local confirmation,
|
|
144
|
+
and audit record. No new review-result transcript entries are written. Existing
|
|
145
|
+
`pi-auto-review` entries in historical sessions remain renderable. Non-TUI
|
|
146
|
+
modes retain best-effort notifications, and a failed TUI widget update falls
|
|
147
|
+
back to the same notification path. UI delivery never changes the authorization
|
|
148
|
+
result.
|
|
142
149
|
|
|
143
150
|
In an interactive TUI, `/auto-review-approve` lists up to ten recent
|
|
144
151
|
non-critical model denials from the current session. Selecting one asks the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Model-backed boundary approval broker for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
51
51
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
52
|
-
"@gotgenes/pi-permission-system": "^
|
|
52
|
+
"@gotgenes/pi-permission-system": "^27.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
56
56
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
57
|
-
"@gotgenes/pi-permission-system": "^
|
|
57
|
+
"@gotgenes/pi-permission-system": "^27.0.0",
|
|
58
58
|
"@types/node": "^26.2.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,11 +11,12 @@ import {
|
|
|
11
11
|
completeSimple,
|
|
12
12
|
type ApiStreamSimpleFunction,
|
|
13
13
|
} from "@earendil-works/pi-ai/compat";
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
import {
|
|
15
|
+
getPermissionsService,
|
|
16
|
+
type AuthorizerLog,
|
|
17
|
+
type AuthorizerVerdict,
|
|
18
|
+
type PermissionQuery,
|
|
19
|
+
type PromptPermissionDetails,
|
|
19
20
|
} from "@gotgenes/pi-permission-system";
|
|
20
21
|
import {
|
|
21
22
|
buildClassifierTranscript,
|
|
@@ -37,14 +38,13 @@ import {
|
|
|
37
38
|
} from "./broker/index.ts";
|
|
38
39
|
import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
|
|
39
40
|
import {
|
|
40
|
-
buildUserReviewEntryData,
|
|
41
41
|
buildUserReviewNotice,
|
|
42
42
|
buildUserReviewStatus,
|
|
43
|
+
buildUserReviewWidgetData,
|
|
43
44
|
notifyUserReview,
|
|
44
|
-
presentUserReview,
|
|
45
45
|
renderUserReviewEntry,
|
|
46
46
|
reviewTargetFromRequest,
|
|
47
|
-
|
|
47
|
+
UserReviewWidgetController,
|
|
48
48
|
USER_REVIEW_ENTRY_TYPE,
|
|
49
49
|
type UserReviewOutcome,
|
|
50
50
|
type UserReviewUsage,
|
|
@@ -251,6 +251,7 @@ type ReviewResult = {
|
|
|
251
251
|
durationMs: number;
|
|
252
252
|
transcript: TranscriptResult;
|
|
253
253
|
summary: ReviewExecutionSummary;
|
|
254
|
+
unavailable?: boolean;
|
|
254
255
|
};
|
|
255
256
|
|
|
256
257
|
class ReviewExecutionError extends Error {
|
|
@@ -273,9 +274,6 @@ const USER_CONFIG_RELATIVE_PATH = join(
|
|
|
273
274
|
"pi-auto-review",
|
|
274
275
|
"config.json",
|
|
275
276
|
);
|
|
276
|
-
const PERMISSIONS_SERVICE_KEY = Symbol.for(
|
|
277
|
-
"@gotgenes/pi-permission-system:service",
|
|
278
|
-
);
|
|
279
277
|
const BOUNDED_SURFACES = new Set(["path", "external_directory"]);
|
|
280
278
|
const REVIEWER_FRAMING_RESERVE_TOKENS = 64;
|
|
281
279
|
const REVIEWER_RETRY_DELAY_MS = 250;
|
|
@@ -728,12 +726,6 @@ type PermissionsService = {
|
|
|
728
726
|
): () => void;
|
|
729
727
|
};
|
|
730
728
|
|
|
731
|
-
function permissionsService(): PermissionsService | undefined {
|
|
732
|
-
return (globalThis as Record<symbol, unknown>)[
|
|
733
|
-
PERMISSIONS_SERVICE_KEY
|
|
734
|
-
] as PermissionsService | undefined;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
729
|
function boundaryRequest(
|
|
738
730
|
ctx: ExtensionContext,
|
|
739
731
|
details: PromptPermissionDetails,
|
|
@@ -1925,6 +1917,9 @@ export function createPiAutoReviewExtension(
|
|
|
1925
1917
|
let context: ExtensionContext | undefined;
|
|
1926
1918
|
let config: Readonly<Config> = trustedConfig;
|
|
1927
1919
|
let disposeAuthorizer: (() => void) | undefined;
|
|
1920
|
+
let registeredSessionId: string | undefined;
|
|
1921
|
+
let registrationEpoch = 0;
|
|
1922
|
+
let shuttingDown = false;
|
|
1928
1923
|
let disposeBrokerService: (() => void) | undefined;
|
|
1929
1924
|
const reviewResults = new Map<string, ReviewResult>();
|
|
1930
1925
|
const telemetryCompleted = new Set<string>();
|
|
@@ -1935,6 +1930,7 @@ export function createPiAutoReviewExtension(
|
|
|
1935
1930
|
const uiAutoConfirmer = new PermissionUiAutoConfirmer(
|
|
1936
1931
|
() => config.autoConfirmBoundedAllows,
|
|
1937
1932
|
);
|
|
1933
|
+
const reviewWidget = new UserReviewWidgetController();
|
|
1938
1934
|
|
|
1939
1935
|
const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
|
|
1940
1936
|
writeOptionalAuditFile(event);
|
|
@@ -1990,6 +1986,7 @@ export function createPiAutoReviewExtension(
|
|
|
1990
1986
|
durationMs: execution.summary.durationMs,
|
|
1991
1987
|
transcript: execution.summary.transcript,
|
|
1992
1988
|
summary: execution.summary,
|
|
1989
|
+
unavailable: true,
|
|
1993
1990
|
});
|
|
1994
1991
|
}
|
|
1995
1992
|
emitTelemetry(
|
|
@@ -2267,6 +2264,12 @@ export function createPiAutoReviewExtension(
|
|
|
2267
2264
|
});
|
|
2268
2265
|
|
|
2269
2266
|
pi.on("session_start", (_event, ctx) => {
|
|
2267
|
+
shuttingDown = false;
|
|
2268
|
+
registrationEpoch++;
|
|
2269
|
+
disposeAuthorizer?.();
|
|
2270
|
+
disposeAuthorizer = undefined;
|
|
2271
|
+
registeredSessionId = undefined;
|
|
2272
|
+
reviewWidget.clear(context ?? ctx);
|
|
2270
2273
|
disposeBrokerService?.();
|
|
2271
2274
|
broker?.clear();
|
|
2272
2275
|
reviewResults.clear();
|
|
@@ -2280,7 +2283,17 @@ export function createPiAutoReviewExtension(
|
|
|
2280
2283
|
);
|
|
2281
2284
|
context = ctx;
|
|
2282
2285
|
broker = createBroker();
|
|
2283
|
-
|
|
2286
|
+
try {
|
|
2287
|
+
disposeBrokerService = publishBoundaryBroker(broker);
|
|
2288
|
+
} catch (error) {
|
|
2289
|
+
if (!(error instanceof Error) ||
|
|
2290
|
+
error.message !== "pi-auto-review boundary broker is already published") {
|
|
2291
|
+
throw error;
|
|
2292
|
+
}
|
|
2293
|
+
// In-process child nodes still need their own reviewer/authorizer.
|
|
2294
|
+
// The process-global broker capability remains owned by the parent.
|
|
2295
|
+
disposeBrokerService = undefined;
|
|
2296
|
+
}
|
|
2284
2297
|
} catch (error) {
|
|
2285
2298
|
context = undefined;
|
|
2286
2299
|
broker = undefined;
|
|
@@ -2300,12 +2313,46 @@ export function createPiAutoReviewExtension(
|
|
|
2300
2313
|
if (context) uiAutoConfirmer.handlePrompt(event, context);
|
|
2301
2314
|
});
|
|
2302
2315
|
|
|
2303
|
-
pi.events.on("permissions:
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2316
|
+
pi.events.on("permissions:decision", (event) => {
|
|
2317
|
+
reviewWidget.permissionDecision(event);
|
|
2318
|
+
});
|
|
2319
|
+
|
|
2320
|
+
pi.events.on("permissions:ready", (event) => {
|
|
2321
|
+
const ready = event && typeof event === "object" && !Array.isArray(event)
|
|
2322
|
+
? event as Record<string, unknown>
|
|
2323
|
+
: undefined;
|
|
2324
|
+
const sessionId = typeof ready?.sessionId === "string" &&
|
|
2325
|
+
ready.sessionId.trim()
|
|
2326
|
+
? ready.sessionId
|
|
2327
|
+
: undefined;
|
|
2328
|
+
if (!sessionId) {
|
|
2329
|
+
console.error(
|
|
2330
|
+
`${EXTENSION_NAME}: ignored permissions:ready without a session id`,
|
|
2331
|
+
);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
if (registeredSessionId === sessionId && disposeAuthorizer) return;
|
|
2335
|
+
|
|
2336
|
+
const epoch = ++registrationEpoch;
|
|
2337
|
+
if (registeredSessionId && registeredSessionId !== sessionId) {
|
|
2307
2338
|
disposeAuthorizer?.();
|
|
2308
|
-
disposeAuthorizer =
|
|
2339
|
+
disposeAuthorizer = undefined;
|
|
2340
|
+
registeredSessionId = undefined;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
if (shuttingDown || epoch !== registrationEpoch || !context) return;
|
|
2344
|
+
const service = getPermissionsService(sessionId) as
|
|
2345
|
+
| PermissionsService
|
|
2346
|
+
| undefined;
|
|
2347
|
+
if (!service) {
|
|
2348
|
+
console.error(
|
|
2349
|
+
`${EXTENSION_NAME}: permissions service unavailable for session ${sessionId}`,
|
|
2350
|
+
);
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
let dispose: (() => void) | undefined;
|
|
2354
|
+
try {
|
|
2355
|
+
dispose = service.registerAuthorizer(
|
|
2309
2356
|
EXTENSION_NAME,
|
|
2310
2357
|
async (details, query, log: AuthorizerLog) => {
|
|
2311
2358
|
const evidence = normalizePermissionEvidence(details);
|
|
@@ -2322,133 +2369,140 @@ export function createPiAutoReviewExtension(
|
|
|
2322
2369
|
surface,
|
|
2323
2370
|
rationale: reason,
|
|
2324
2371
|
};
|
|
2325
|
-
|
|
2326
|
-
pi,
|
|
2327
|
-
context,
|
|
2328
|
-
buildUserReviewNotice(unavailable),
|
|
2329
|
-
buildUserReviewEntryData(unavailable),
|
|
2330
|
-
);
|
|
2372
|
+
notifyUserReview(context, buildUserReviewNotice(unavailable));
|
|
2331
2373
|
return { kind: "deny", reason };
|
|
2332
2374
|
}
|
|
2333
2375
|
|
|
2334
2376
|
const request = boundaryRequest(context, details, query);
|
|
2335
2377
|
const target = reviewTargetFromRequest(request);
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2378
|
+
const reviewContext = context;
|
|
2379
|
+
const widgetGeneration = reviewWidget.begin(request.id, reviewContext, {
|
|
2380
|
+
surface,
|
|
2381
|
+
target,
|
|
2382
|
+
model: config.model,
|
|
2383
|
+
});
|
|
2384
|
+
const decision = await broker.review(request, {
|
|
2385
|
+
sessionId: reviewContext.sessionManager.getSessionId(),
|
|
2386
|
+
scopeKey: currentTurnScope(reviewContext),
|
|
2387
|
+
issueGrant: false,
|
|
2388
|
+
});
|
|
2389
|
+
const result = reviewResults.get(request.id);
|
|
2390
|
+
reviewResults.delete(request.id);
|
|
2391
|
+
const allowCapped =
|
|
2392
|
+
decision.kind === "allow" && boundedRequest(surface);
|
|
2393
|
+
const autoConfirmQueued =
|
|
2394
|
+
allowCapped &&
|
|
2395
|
+
reviewContext.mode === "tui" &&
|
|
2396
|
+
reviewContext.hasUI &&
|
|
2397
|
+
uiAutoConfirmer.stage(request.id, surface);
|
|
2398
|
+
|
|
2399
|
+
let userOutcome: UserReviewOutcome;
|
|
2400
|
+
if (decision.kind === "deny" && decision.circuitBreakerTripped) {
|
|
2401
|
+
userOutcome = "circuit_breaker";
|
|
2402
|
+
} else if (allowCapped && autoConfirmQueued) {
|
|
2403
|
+
userOutcome = "auto_confirm";
|
|
2404
|
+
} else if (allowCapped) {
|
|
2405
|
+
userOutcome = "needs_confirmation";
|
|
2406
|
+
} else if (decision.kind === "allow") {
|
|
2407
|
+
userOutcome = "allow";
|
|
2408
|
+
} else if (decision.kind === "defer") {
|
|
2409
|
+
userOutcome = "defer";
|
|
2410
|
+
} else {
|
|
2411
|
+
userOutcome = "deny";
|
|
2412
|
+
}
|
|
2413
|
+
const reviewMeta = userReviewMetaFromResult(result, config.model);
|
|
2414
|
+
const noticeInput = {
|
|
2415
|
+
outcome: result?.unavailable ? "unavailable" as const : userOutcome,
|
|
2416
|
+
surface,
|
|
2417
|
+
target,
|
|
2418
|
+
rationale: decision.review.rationale,
|
|
2419
|
+
recoveryCommand:
|
|
2420
|
+
decision.kind === "deny"
|
|
2421
|
+
? decision.recoveryCommand
|
|
2422
|
+
: undefined,
|
|
2423
|
+
...reviewMeta,
|
|
2424
|
+
};
|
|
2425
|
+
const notice = buildUserReviewNotice(noticeInput);
|
|
2426
|
+
reviewWidget.complete(
|
|
2427
|
+
request.id,
|
|
2428
|
+
widgetGeneration,
|
|
2429
|
+
reviewContext,
|
|
2430
|
+
notice,
|
|
2431
|
+
buildUserReviewWidgetData(noticeInput),
|
|
2339
2432
|
);
|
|
2340
|
-
try {
|
|
2341
|
-
const decision = await broker.review(request, {
|
|
2342
|
-
sessionId: context.sessionManager.getSessionId(),
|
|
2343
|
-
scopeKey: currentTurnScope(context),
|
|
2344
|
-
issueGrant: false,
|
|
2345
|
-
});
|
|
2346
|
-
const result = reviewResults.get(request.id);
|
|
2347
|
-
reviewResults.delete(request.id);
|
|
2348
|
-
const allowCapped =
|
|
2349
|
-
decision.kind === "allow" && boundedRequest(surface);
|
|
2350
|
-
const autoConfirmQueued =
|
|
2351
|
-
allowCapped &&
|
|
2352
|
-
context.mode === "tui" &&
|
|
2353
|
-
context.hasUI &&
|
|
2354
|
-
uiAutoConfirmer.stage(request.id, surface);
|
|
2355
|
-
|
|
2356
|
-
let userOutcome: UserReviewOutcome;
|
|
2357
|
-
if (decision.kind === "deny" && decision.circuitBreakerTripped) {
|
|
2358
|
-
userOutcome = "circuit_breaker";
|
|
2359
|
-
} else if (allowCapped && autoConfirmQueued) {
|
|
2360
|
-
userOutcome = "auto_confirm";
|
|
2361
|
-
} else if (allowCapped) {
|
|
2362
|
-
userOutcome = "needs_confirmation";
|
|
2363
|
-
} else if (decision.kind === "allow") {
|
|
2364
|
-
userOutcome = "allow";
|
|
2365
|
-
} else if (decision.kind === "defer") {
|
|
2366
|
-
userOutcome = "defer";
|
|
2367
|
-
} else {
|
|
2368
|
-
userOutcome = "deny";
|
|
2369
|
-
}
|
|
2370
|
-
const reviewMeta = userReviewMetaFromResult(result, config.model);
|
|
2371
|
-
const noticeInput = {
|
|
2372
|
-
outcome: userOutcome,
|
|
2373
|
-
surface,
|
|
2374
|
-
target,
|
|
2375
|
-
rationale: decision.review.rationale,
|
|
2376
|
-
recoveryCommand:
|
|
2377
|
-
decision.kind === "deny"
|
|
2378
|
-
? decision.recoveryCommand
|
|
2379
|
-
: undefined,
|
|
2380
|
-
...reviewMeta,
|
|
2381
|
-
};
|
|
2382
|
-
presentUserReview(
|
|
2383
|
-
pi,
|
|
2384
|
-
context,
|
|
2385
|
-
buildUserReviewNotice(noticeInput),
|
|
2386
|
-
buildUserReviewEntryData(noticeInput),
|
|
2387
|
-
);
|
|
2388
|
-
|
|
2389
|
-
log.review("pi_auto_review_decision", {
|
|
2390
|
-
requestId: request.id,
|
|
2391
|
-
surface,
|
|
2392
|
-
model: config.model,
|
|
2393
|
-
reviewerModel: reviewMeta.model,
|
|
2394
|
-
outcome: allowCapped ? "defer" : decision.kind,
|
|
2395
|
-
reviewerOutcome: decision.review.outcome,
|
|
2396
|
-
riskLevel: decision.review.riskLevel,
|
|
2397
|
-
userAuthorization: decision.review.userAuthorization,
|
|
2398
|
-
rationale: decision.review.rationale,
|
|
2399
|
-
allowCapped,
|
|
2400
|
-
autoConfirmQueued,
|
|
2401
|
-
userOutcome,
|
|
2402
|
-
circuitBreakerTripped:
|
|
2403
|
-
decision.kind === "deny"
|
|
2404
|
-
? decision.circuitBreakerTripped
|
|
2405
|
-
: false,
|
|
2406
|
-
attempts: result?.attempts ?? 0,
|
|
2407
|
-
retryErrors: result?.retryErrors ?? [],
|
|
2408
|
-
durationMs: result?.durationMs,
|
|
2409
|
-
usageAvailability: reviewMeta.usage?.availability,
|
|
2410
|
-
usage: reviewMeta.usage,
|
|
2411
|
-
transcriptUserCharacters: result?.transcript.userCharacters,
|
|
2412
|
-
transcriptToolCharacters: result?.transcript.toolCharacters,
|
|
2413
|
-
transcriptRelevantResultCharacters:
|
|
2414
|
-
result?.transcript.relevantResultCharacters,
|
|
2415
|
-
transcriptTruncated: result?.transcript.truncated,
|
|
2416
|
-
command: request.command,
|
|
2417
|
-
path: request.path,
|
|
2418
|
-
resolvedPath: request.resolvedPath,
|
|
2419
|
-
destination: request.destination,
|
|
2420
|
-
agentName: request.agentName,
|
|
2421
|
-
requesterSessionId: request.requesterSessionId,
|
|
2422
|
-
accessIntent: request.accessIntent,
|
|
2423
|
-
authorization:
|
|
2424
|
-
decision.kind === "allow"
|
|
2425
|
-
? decision.authorization
|
|
2426
|
-
: undefined,
|
|
2427
|
-
});
|
|
2428
2433
|
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2434
|
+
log.review("pi_auto_review_decision", {
|
|
2435
|
+
requestId: request.id,
|
|
2436
|
+
toolCallId: request.toolCallId,
|
|
2437
|
+
surface,
|
|
2438
|
+
model: config.model,
|
|
2439
|
+
reviewerModel: reviewMeta.model,
|
|
2440
|
+
outcome: allowCapped ? "defer" : decision.kind,
|
|
2441
|
+
reviewerOutcome: decision.review.outcome,
|
|
2442
|
+
riskLevel: decision.review.riskLevel,
|
|
2443
|
+
userAuthorization: decision.review.userAuthorization,
|
|
2444
|
+
rationale: decision.review.rationale,
|
|
2445
|
+
allowCapped,
|
|
2446
|
+
autoConfirmQueued,
|
|
2447
|
+
userOutcome,
|
|
2448
|
+
circuitBreakerTripped:
|
|
2449
|
+
decision.kind === "deny"
|
|
2450
|
+
? decision.circuitBreakerTripped
|
|
2451
|
+
: false,
|
|
2452
|
+
attempts: result?.attempts ?? 0,
|
|
2453
|
+
retryErrors: result?.retryErrors ?? [],
|
|
2454
|
+
durationMs: result?.durationMs,
|
|
2455
|
+
usageAvailability: reviewMeta.usage?.availability,
|
|
2456
|
+
usage: reviewMeta.usage,
|
|
2457
|
+
transcriptUserCharacters: result?.transcript.userCharacters,
|
|
2458
|
+
transcriptToolCharacters: result?.transcript.toolCharacters,
|
|
2459
|
+
transcriptRelevantResultCharacters:
|
|
2460
|
+
result?.transcript.relevantResultCharacters,
|
|
2461
|
+
transcriptTruncated: result?.transcript.truncated,
|
|
2462
|
+
command: request.command,
|
|
2463
|
+
path: request.path,
|
|
2464
|
+
resolvedPath: request.resolvedPath,
|
|
2465
|
+
destination: request.destination,
|
|
2466
|
+
agentName: request.agentName,
|
|
2467
|
+
requesterSessionId: request.requesterSessionId,
|
|
2468
|
+
accessIntent: request.accessIntent,
|
|
2469
|
+
authorization:
|
|
2470
|
+
decision.kind === "allow"
|
|
2471
|
+
? decision.authorization
|
|
2472
|
+
: undefined,
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
if (allowCapped || decision.kind === "defer") {
|
|
2476
|
+
return { kind: "defer" };
|
|
2447
2477
|
}
|
|
2478
|
+
if (decision.kind === "allow") return { kind: "allow" };
|
|
2479
|
+
const denyInstruction =
|
|
2480
|
+
decision.denialSource === "hard-deny"
|
|
2481
|
+
? LOCAL_HARD_DENY_AGENT_INSTRUCTION
|
|
2482
|
+
: decision.recoveryCommand === "/auto-review-break-glass"
|
|
2483
|
+
? REVIEWER_CRITICAL_DENY_AGENT_INSTRUCTION
|
|
2484
|
+
: decision.recoveryCommand === "/auto-review-approve"
|
|
2485
|
+
? REVIEWER_NONCRITICAL_DENY_AGENT_INSTRUCTION
|
|
2486
|
+
: "Automatic policy critically denied this action and break-glass authorization is disabled. Do not retry, rephrase, or circumvent it.";
|
|
2487
|
+
return {
|
|
2488
|
+
kind: "deny",
|
|
2489
|
+
reason: `${decision.review.rationale} ${denyInstruction}`,
|
|
2490
|
+
};
|
|
2448
2491
|
},
|
|
2449
2492
|
);
|
|
2450
|
-
|
|
2493
|
+
if (shuttingDown || epoch !== registrationEpoch || !context) {
|
|
2494
|
+
dispose();
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
disposeAuthorizer?.();
|
|
2498
|
+
disposeAuthorizer = dispose;
|
|
2499
|
+
registeredSessionId = sessionId;
|
|
2500
|
+
writeOptionalAuditFile({
|
|
2501
|
+
type: "authorizer_registered",
|
|
2502
|
+
sessionId,
|
|
2503
|
+
});
|
|
2451
2504
|
} catch (error) {
|
|
2505
|
+
dispose?.();
|
|
2452
2506
|
console.error(
|
|
2453
2507
|
`${EXTENSION_NAME}: authorizer registration failed: ${
|
|
2454
2508
|
error instanceof Error ? error.message : String(error)
|
|
@@ -2458,9 +2512,12 @@ export function createPiAutoReviewExtension(
|
|
|
2458
2512
|
});
|
|
2459
2513
|
|
|
2460
2514
|
pi.on("session_shutdown", () => {
|
|
2461
|
-
|
|
2515
|
+
shuttingDown = true;
|
|
2516
|
+
registrationEpoch++;
|
|
2517
|
+
reviewWidget.clear(context);
|
|
2462
2518
|
disposeAuthorizer?.();
|
|
2463
2519
|
disposeAuthorizer = undefined;
|
|
2520
|
+
registeredSessionId = undefined;
|
|
2464
2521
|
disposeBrokerService?.();
|
|
2465
2522
|
disposeBrokerService = undefined;
|
|
2466
2523
|
broker?.clear();
|
package/src/user-feedback.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionContext,
|
|
4
|
-
} from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
2
|
|
|
6
3
|
export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
|
|
7
4
|
export const USER_REVIEW_ENTRY_TYPE = "pi-auto-review";
|
|
5
|
+
export const USER_REVIEW_WIDGET_KEY = "pi-auto-review";
|
|
8
6
|
|
|
9
7
|
export type UserReviewOutcome =
|
|
10
8
|
| "allow"
|
|
@@ -45,6 +43,14 @@ export function compactReviewText(value: unknown, max = 90): string {
|
|
|
45
43
|
.slice(0, max);
|
|
46
44
|
}
|
|
47
45
|
|
|
46
|
+
/** Collapse whitespace and visibly truncate bounded widget text. */
|
|
47
|
+
export function truncateReviewText(value: unknown, max: number): string {
|
|
48
|
+
const compact = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
49
|
+
if (compact.length <= max) return compact;
|
|
50
|
+
if (max <= 1) return "…".slice(0, max);
|
|
51
|
+
return `${compact.slice(0, max - 1).trimEnd()}…`;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
export function reviewTargetFromRequest(request: {
|
|
49
55
|
resolvedPath?: string;
|
|
50
56
|
path?: string;
|
|
@@ -62,7 +68,7 @@ export function reviewTargetFromRequest(request: {
|
|
|
62
68
|
request.toolName ??
|
|
63
69
|
request.skillName ??
|
|
64
70
|
request.operation;
|
|
65
|
-
const compact =
|
|
71
|
+
const compact = truncateReviewText(target, 100);
|
|
66
72
|
return compact || undefined;
|
|
67
73
|
}
|
|
68
74
|
|
|
@@ -101,15 +107,15 @@ export function formatReviewUsage(usage: UserReviewUsage): string | undefined {
|
|
|
101
107
|
if (input !== undefined) {
|
|
102
108
|
const cache =
|
|
103
109
|
usage.cacheRead && usage.cacheRead > 0
|
|
104
|
-
? ` (${formatReviewTokenCount(usage.cacheRead)} cache)`
|
|
110
|
+
? ` (${formatReviewTokenCount(usage.cacheRead)} toks cache)`
|
|
105
111
|
: "";
|
|
106
|
-
parts.push(`${formatReviewTokenCount(input)} in${cache}`);
|
|
112
|
+
parts.push(`${formatReviewTokenCount(input)} toks in${cache}`);
|
|
107
113
|
}
|
|
108
114
|
if (usage.output !== undefined) {
|
|
109
|
-
parts.push(`${formatReviewTokenCount(usage.output)} out`);
|
|
115
|
+
parts.push(`${formatReviewTokenCount(usage.output)} toks out`);
|
|
110
116
|
}
|
|
111
117
|
if (parts.length === 0 && usage.totalTokens !== undefined) {
|
|
112
|
-
parts.push(`${formatReviewTokenCount(usage.totalTokens)}
|
|
118
|
+
parts.push(`${formatReviewTokenCount(usage.totalTokens)} toks`);
|
|
113
119
|
}
|
|
114
120
|
if (parts.length === 0) return undefined;
|
|
115
121
|
const approx = usage.availability === "estimated" ? "~" : "";
|
|
@@ -193,6 +199,16 @@ function joinNoticeLines(lines: Array<string | undefined>): string[] {
|
|
|
193
199
|
.filter((line) => line.length > 0);
|
|
194
200
|
}
|
|
195
201
|
|
|
202
|
+
function reviewTargetRationaleLine(input: {
|
|
203
|
+
target?: string;
|
|
204
|
+
rationale?: string;
|
|
205
|
+
}): string | undefined {
|
|
206
|
+
const target = truncateReviewText(input.target, 100);
|
|
207
|
+
const rationale = truncateReviewText(input.rationale, 180);
|
|
208
|
+
if (target && rationale) return `${target} · ${rationale}`;
|
|
209
|
+
return target || rationale || undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
196
212
|
export type UserReviewNoticeInput = {
|
|
197
213
|
outcome: UserReviewOutcome;
|
|
198
214
|
surface: string;
|
|
@@ -211,6 +227,24 @@ export type UserReviewEntryData = {
|
|
|
211
227
|
lines: string[];
|
|
212
228
|
};
|
|
213
229
|
|
|
230
|
+
export type UserReviewGroupMember = UserReviewNoticeInput & {
|
|
231
|
+
requestId: string;
|
|
232
|
+
permissionResult?: "allow" | "deny";
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export type UserReviewGroupEntryData = {
|
|
236
|
+
kind: "group";
|
|
237
|
+
type: UserReviewNotice["type"];
|
|
238
|
+
sessionId: string;
|
|
239
|
+
toolCallId: string;
|
|
240
|
+
fullCommand?: string;
|
|
241
|
+
members: UserReviewGroupMember[];
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export type AnyUserReviewEntryData =
|
|
245
|
+
| UserReviewEntryData
|
|
246
|
+
| UserReviewGroupEntryData;
|
|
247
|
+
|
|
214
248
|
type UserReviewTheme = {
|
|
215
249
|
fg(color: string, text: string): string;
|
|
216
250
|
italic(text: string): string;
|
|
@@ -258,11 +292,21 @@ function outcomeVerb(outcome: UserReviewOutcome): string {
|
|
|
258
292
|
}
|
|
259
293
|
}
|
|
260
294
|
|
|
295
|
+
function memberOutcomeText(outcome: UserReviewOutcome): string {
|
|
296
|
+
switch (outcome) {
|
|
297
|
+
case "auto_confirm":
|
|
298
|
+
return "allowed · auto-confirm";
|
|
299
|
+
case "needs_confirmation":
|
|
300
|
+
return "allowed · confirm locally";
|
|
301
|
+
default:
|
|
302
|
+
return outcomeVerb(outcome);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
261
306
|
export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
|
|
262
307
|
return joinNoticeLines([
|
|
263
308
|
reviewHeadline(input.outcome, input.surface),
|
|
264
|
-
|
|
265
|
-
compactReviewText(input.rationale, 180) || undefined,
|
|
309
|
+
reviewTargetRationaleLine(input),
|
|
266
310
|
recoveryLine(input.outcome, input.recoveryCommand),
|
|
267
311
|
formatReviewMeta({
|
|
268
312
|
model: input.model,
|
|
@@ -275,7 +319,7 @@ export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
|
|
|
275
319
|
|
|
276
320
|
/**
|
|
277
321
|
* User-facing (not agent-facing) notice after a review decision.
|
|
278
|
-
* Structured for TUI wrapping: headline, target
|
|
322
|
+
* Structured for TUI wrapping: headline, target+rationale, then model/tokens.
|
|
279
323
|
*/
|
|
280
324
|
export function buildUserReviewNotice(
|
|
281
325
|
input: UserReviewNoticeInput,
|
|
@@ -297,10 +341,7 @@ export function buildUserReviewEntryData(
|
|
|
297
341
|
}
|
|
298
342
|
|
|
299
343
|
export function formatUserReviewQuoteMessage(message: string): string {
|
|
300
|
-
return message
|
|
301
|
-
.split("\n")
|
|
302
|
-
.map((line) => `│ ${line}`)
|
|
303
|
-
.join("\n");
|
|
344
|
+
return message;
|
|
304
345
|
}
|
|
305
346
|
|
|
306
347
|
function displayWidth(text: string): number {
|
|
@@ -362,11 +403,9 @@ export function renderUserReviewQuoteLines(
|
|
|
362
403
|
theme: UserReviewTheme,
|
|
363
404
|
width: number,
|
|
364
405
|
): string[] {
|
|
365
|
-
const contentWidth = Math.max(1, width
|
|
366
|
-
const border = theme.fg("mdQuoteBorder", "│ ");
|
|
406
|
+
const contentWidth = Math.max(1, width);
|
|
367
407
|
const verb = outcomeVerb(data.outcome);
|
|
368
408
|
const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
|
|
369
|
-
const numberAnsi = theme.getFgAnsi("success");
|
|
370
409
|
const rendered: string[] = [];
|
|
371
410
|
for (const [index, line] of data.lines.entries()) {
|
|
372
411
|
for (const visual of wrapReviewDisplayText(line, contentWidth)) {
|
|
@@ -377,24 +416,100 @@ export function renderUserReviewQuoteLines(
|
|
|
377
416
|
`${verbAnsi}${verb}\x1b[0m`,
|
|
378
417
|
);
|
|
379
418
|
}
|
|
380
|
-
|
|
381
|
-
painted = painted.replace(
|
|
382
|
-
/(\d+(?:\.\d+)?[kM]?)(?=\s(?:in|out|tok|cache|calls)|ms\b|s\b)/g,
|
|
383
|
-
`${numberAnsi}$1\x1b[0m`,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
rendered.push(border + withQuoteStyle(theme, painted));
|
|
419
|
+
rendered.push(withQuoteStyle(theme, painted));
|
|
387
420
|
}
|
|
388
421
|
}
|
|
389
422
|
return rendered;
|
|
390
423
|
}
|
|
391
424
|
|
|
425
|
+
function groupOutcome(members: readonly UserReviewGroupMember[]): UserReviewOutcome {
|
|
426
|
+
if (members.some((member) => member.permissionResult === "deny")) return "deny";
|
|
427
|
+
for (const outcome of [
|
|
428
|
+
"circuit_breaker",
|
|
429
|
+
"deny",
|
|
430
|
+
"unavailable",
|
|
431
|
+
"defer",
|
|
432
|
+
"needs_confirmation",
|
|
433
|
+
"auto_confirm",
|
|
434
|
+
] as const) {
|
|
435
|
+
if (members.some((member) => member.outcome === outcome)) return outcome;
|
|
436
|
+
}
|
|
437
|
+
return "allow";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function groupHeadline(data: UserReviewGroupEntryData): string {
|
|
441
|
+
const outcome = groupOutcome(data.members);
|
|
442
|
+
const result = outcome === "deny" || outcome === "circuit_breaker" ||
|
|
443
|
+
outcome === "unavailable"
|
|
444
|
+
? "denied"
|
|
445
|
+
: outcome === "defer" || outcome === "needs_confirmation"
|
|
446
|
+
? "deferred"
|
|
447
|
+
: "allowed";
|
|
448
|
+
return `Auto-review · ${result} · ${data.members.length} checks`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function buildUserReviewGroupLines(
|
|
452
|
+
data: UserReviewGroupEntryData,
|
|
453
|
+
): UserReviewEntryData {
|
|
454
|
+
if (data.members.length === 1) {
|
|
455
|
+
const member = data.members[0];
|
|
456
|
+
const lines = buildUserReviewLines(member);
|
|
457
|
+
if (member.permissionResult === "deny" && member.outcome !== "deny") {
|
|
458
|
+
lines.push("Local confirmation · denied");
|
|
459
|
+
}
|
|
460
|
+
return { outcome: groupOutcome(data.members), type: data.type, lines };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const lines: string[] = [groupHeadline(data)];
|
|
464
|
+
const command = String(data.fullCommand ?? "")
|
|
465
|
+
.replace(/\s+/g, " ")
|
|
466
|
+
.trim();
|
|
467
|
+
if (command) lines.push(command);
|
|
468
|
+
for (const member of data.members) {
|
|
469
|
+
const summary = joinNoticeLines([
|
|
470
|
+
member.surface,
|
|
471
|
+
memberOutcomeText(member.outcome),
|
|
472
|
+
reviewTargetRationaleLine(member),
|
|
473
|
+
]).join(" · ");
|
|
474
|
+
lines.push(
|
|
475
|
+
summary,
|
|
476
|
+
...joinNoticeLines([
|
|
477
|
+
recoveryLine(member.outcome, member.recoveryCommand),
|
|
478
|
+
member.permissionResult === "deny" && member.outcome !== "deny"
|
|
479
|
+
? "Local confirmation · denied"
|
|
480
|
+
: undefined,
|
|
481
|
+
formatReviewMeta(member),
|
|
482
|
+
]),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return { outcome: groupOutcome(data.members), type: data.type, lines };
|
|
486
|
+
}
|
|
487
|
+
|
|
392
488
|
export function renderUserReviewEntry(
|
|
393
489
|
entry: { data?: unknown },
|
|
394
490
|
_options: unknown,
|
|
395
491
|
theme: UserReviewTheme,
|
|
396
492
|
): { render(width: number): string[]; invalidate(): void } | undefined {
|
|
397
493
|
const data = entry.data;
|
|
494
|
+
if (
|
|
495
|
+
data &&
|
|
496
|
+
typeof data === "object" &&
|
|
497
|
+
(data as UserReviewGroupEntryData).kind === "group" &&
|
|
498
|
+
Array.isArray((data as UserReviewGroupEntryData).members) &&
|
|
499
|
+
(data as UserReviewGroupEntryData).members.length > 0
|
|
500
|
+
) {
|
|
501
|
+
const parsed = data as UserReviewGroupEntryData;
|
|
502
|
+
return {
|
|
503
|
+
render(width: number) {
|
|
504
|
+
return renderUserReviewQuoteLines(
|
|
505
|
+
buildUserReviewGroupLines(parsed),
|
|
506
|
+
theme,
|
|
507
|
+
width,
|
|
508
|
+
);
|
|
509
|
+
},
|
|
510
|
+
invalidate() {},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
398
513
|
if (
|
|
399
514
|
!data ||
|
|
400
515
|
typeof data !== "object" ||
|
|
@@ -412,20 +527,6 @@ export function renderUserReviewEntry(
|
|
|
412
527
|
};
|
|
413
528
|
}
|
|
414
529
|
|
|
415
|
-
function presentUserReviewEntry(
|
|
416
|
-
pi: ExtensionAPI | undefined,
|
|
417
|
-
ctx: ExtensionContext | undefined,
|
|
418
|
-
data: UserReviewEntryData,
|
|
419
|
-
): boolean {
|
|
420
|
-
if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
|
|
421
|
-
try {
|
|
422
|
-
pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
|
|
423
|
-
return true;
|
|
424
|
-
} catch {
|
|
425
|
-
return false;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
530
|
/** Best-effort toast; never throws into the authorizer path. */
|
|
430
531
|
export function notifyUserReview(
|
|
431
532
|
ctx: ExtensionContext | undefined,
|
|
@@ -439,27 +540,160 @@ export function notifyUserReview(
|
|
|
439
540
|
}
|
|
440
541
|
}
|
|
441
542
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
543
|
+
export type UserReviewWidgetData = UserReviewEntryData & {
|
|
544
|
+
phase: "reviewing" | "complete";
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
export function buildUserReviewingWidgetData(input: {
|
|
548
|
+
surface: string;
|
|
549
|
+
target?: string;
|
|
550
|
+
model?: string;
|
|
551
|
+
}): UserReviewWidgetData {
|
|
552
|
+
const target = truncateReviewText(input.target, 100);
|
|
553
|
+
const model = formatReviewModelName(input.model) ?? "reviewer";
|
|
554
|
+
return {
|
|
555
|
+
phase: "reviewing",
|
|
556
|
+
outcome: "defer",
|
|
557
|
+
type: "info",
|
|
558
|
+
lines: joinNoticeLines([
|
|
559
|
+
`Auto-review · reviewing · ${input.surface}`,
|
|
560
|
+
target,
|
|
561
|
+
`Waiting for ${model}…`,
|
|
562
|
+
]),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function buildUserReviewWidgetData(
|
|
567
|
+
input: UserReviewNoticeInput,
|
|
568
|
+
): UserReviewWidgetData {
|
|
569
|
+
return {
|
|
570
|
+
phase: "complete",
|
|
571
|
+
...buildUserReviewEntryData(input),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function renderUserReviewWidgetLines(
|
|
576
|
+
data: UserReviewWidgetData,
|
|
577
|
+
theme: UserReviewTheme,
|
|
578
|
+
width: number,
|
|
579
|
+
): string[] {
|
|
580
|
+
const rendered: string[] = [];
|
|
581
|
+
const verb = data.phase === "reviewing"
|
|
582
|
+
? "reviewing"
|
|
583
|
+
: outcomeVerb(data.outcome);
|
|
584
|
+
const accent = data.phase === "reviewing"
|
|
585
|
+
? "muted"
|
|
586
|
+
: outcomeAccent(data.outcome);
|
|
587
|
+
const verbAnsi = theme.getFgAnsi(accent);
|
|
588
|
+
const mutedAnsi = theme.getFgAnsi("muted");
|
|
589
|
+
for (const [index, line] of data.lines.entries()) {
|
|
590
|
+
for (const visual of wrapReviewDisplayText(line, Math.max(1, width))) {
|
|
591
|
+
let painted = `${mutedAnsi}${visual}\x1b[0m`;
|
|
592
|
+
if (index === 0) {
|
|
593
|
+
painted = painted.replace(
|
|
594
|
+
verb,
|
|
595
|
+
`${verbAnsi}${verb}\x1b[0m${mutedAnsi}`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
rendered.push(painted);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return rendered;
|
|
451
602
|
}
|
|
452
603
|
|
|
453
|
-
|
|
454
|
-
export function setUserReviewStatus(
|
|
604
|
+
function setWidget(
|
|
455
605
|
ctx: ExtensionContext | undefined,
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
)
|
|
459
|
-
if (!ctx?.hasUI) return;
|
|
606
|
+
data: UserReviewWidgetData | undefined,
|
|
607
|
+
): boolean {
|
|
608
|
+
if (!ctx?.hasUI || ctx.mode !== "tui") return false;
|
|
460
609
|
try {
|
|
461
|
-
ctx.ui.
|
|
610
|
+
ctx.ui.setWidget(
|
|
611
|
+
USER_REVIEW_WIDGET_KEY,
|
|
612
|
+
data
|
|
613
|
+
? (_tui, theme) => ({
|
|
614
|
+
render: (width: number) =>
|
|
615
|
+
renderUserReviewWidgetLines(data, theme, width),
|
|
616
|
+
invalidate() {},
|
|
617
|
+
})
|
|
618
|
+
: undefined,
|
|
619
|
+
{ placement: "aboveEditor" },
|
|
620
|
+
);
|
|
621
|
+
return true;
|
|
462
622
|
} catch {
|
|
463
|
-
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Owns the single live widget and rejects stale concurrent completions. */
|
|
628
|
+
export class UserReviewWidgetController {
|
|
629
|
+
#generation = 0;
|
|
630
|
+
#current?: {
|
|
631
|
+
requestId: string;
|
|
632
|
+
generation: number;
|
|
633
|
+
ctx: ExtensionContext;
|
|
634
|
+
data?: UserReviewWidgetData;
|
|
635
|
+
notice?: UserReviewNotice;
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
begin(
|
|
639
|
+
requestId: string,
|
|
640
|
+
ctx: ExtensionContext,
|
|
641
|
+
input: { surface: string; target?: string; model?: string },
|
|
642
|
+
): number {
|
|
643
|
+
const generation = ++this.#generation;
|
|
644
|
+
this.#current = { requestId, generation, ctx };
|
|
645
|
+
setWidget(ctx, buildUserReviewingWidgetData(input));
|
|
646
|
+
return generation;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
complete(
|
|
650
|
+
requestId: string,
|
|
651
|
+
generation: number,
|
|
652
|
+
ctx: ExtensionContext,
|
|
653
|
+
notice: UserReviewNotice,
|
|
654
|
+
data: UserReviewWidgetData,
|
|
655
|
+
): void {
|
|
656
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
657
|
+
notifyUserReview(ctx, notice);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (!this.#isCurrent(requestId, generation)) return;
|
|
661
|
+
this.#current = { requestId, generation, ctx, notice, data };
|
|
662
|
+
if (!setWidget(ctx, data)) notifyUserReview(ctx, notice);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
permissionDecision(event: unknown): void {
|
|
666
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
667
|
+
const record = event as Record<string, unknown>;
|
|
668
|
+
const current = this.#current;
|
|
669
|
+
if (
|
|
670
|
+
record.result !== "deny" ||
|
|
671
|
+
typeof record.requestId !== "string" ||
|
|
672
|
+
record.requestId !== current?.requestId ||
|
|
673
|
+
!current.data ||
|
|
674
|
+
current.data.lines.includes("Local confirmation · denied")
|
|
675
|
+
) return;
|
|
676
|
+
const data = {
|
|
677
|
+
...current.data,
|
|
678
|
+
lines: [...current.data.lines, "Local confirmation · denied"],
|
|
679
|
+
};
|
|
680
|
+
const notice = {
|
|
681
|
+
type: current.notice?.type ?? data.type,
|
|
682
|
+
message: data.lines.join("\n"),
|
|
683
|
+
};
|
|
684
|
+
this.#current = { ...current, data, notice };
|
|
685
|
+
if (!setWidget(current.ctx, data)) notifyUserReview(current.ctx, notice);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
clear(ctx?: ExtensionContext): void {
|
|
689
|
+
++this.#generation;
|
|
690
|
+
const current = this.#current;
|
|
691
|
+
this.#current = undefined;
|
|
692
|
+
if (current) setWidget(ctx ?? current.ctx, undefined);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
#isCurrent(requestId: string, generation: number): boolean {
|
|
696
|
+
return this.#current?.requestId === requestId &&
|
|
697
|
+
this.#current.generation === generation;
|
|
464
698
|
}
|
|
465
699
|
}
|