@erichll/pi-auto-review 0.9.0 → 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 CHANGED
@@ -1,5 +1,22 @@
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
+
3
20
  ## 0.9.0 - 2026-08-22
4
21
 
5
22
  - Require `@gotgenes/pi-permission-system` `^27.0.0` and register through the
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. Version 0.9.0 requires permission-system 27.x:
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
@@ -132,16 +132,20 @@ only a fixed, budget-checked schema correction.
132
132
 
133
133
  ## Operator feedback and exact retry
134
134
 
135
- Interactive sessions show a best-effort footer while review is running and
136
- structured results for allow, local confirmation, defer, deny, or circuit
137
- breaker outcomes. Local permission-system asks that belong to the same tool
138
- call are grouped into one TUI entry; each request still has its own model call,
139
- verdict, grant, local confirmation, and audit record. Forwarded asks and
140
- non-TUI modes remain immediate and ungrouped. Group members retain their own
141
- surface, outcome, target, rationale, model, token usage, and duration. UI
142
- entries use unbordered muted text and place each target and rationale together
143
- on the same line as that member's surface and outcome. UI delivery never
144
- changes the authorization result.
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.
145
149
 
146
150
  In an interactive TUI, `/auto-review-approve` lists up to ten recent
147
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.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Model-backed boundary approval broker for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 type {
15
- AuthorizerLog,
16
- AuthorizerVerdict,
17
- PermissionQuery,
18
- PromptPermissionDetails,
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,15 +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
- ReviewEntryBatcher,
46
45
  renderUserReviewEntry,
47
46
  reviewTargetFromRequest,
48
- setUserReviewStatus,
47
+ UserReviewWidgetController,
49
48
  USER_REVIEW_ENTRY_TYPE,
50
49
  type UserReviewOutcome,
51
50
  type UserReviewUsage,
@@ -252,6 +251,7 @@ type ReviewResult = {
252
251
  durationMs: number;
253
252
  transcript: TranscriptResult;
254
253
  summary: ReviewExecutionSummary;
254
+ unavailable?: boolean;
255
255
  };
256
256
 
257
257
  class ReviewExecutionError extends Error {
@@ -726,52 +726,6 @@ type PermissionsService = {
726
726
  ): () => void;
727
727
  };
728
728
 
729
- function promptPayloadRecord(
730
- details: PromptPermissionDetails,
731
- ): Record<string, unknown> | undefined {
732
- const payload = (details as unknown as Record<string, unknown>).payload;
733
- return payload && typeof payload === "object" && !Array.isArray(payload)
734
- ? payload as Record<string, unknown>
735
- : undefined;
736
- }
737
-
738
- function promptIsForwarded(details: PromptPermissionDetails): boolean {
739
- if ((details as unknown as Record<string, unknown>).forwarding) return true;
740
- const request = promptPayloadRecord(details)?.request;
741
- if (!request || typeof request !== "object" || Array.isArray(request)) {
742
- return false;
743
- }
744
- const requester = (request as Record<string, unknown>).requester;
745
- return Boolean(
746
- requester && typeof requester === "object" && !Array.isArray(requester) &&
747
- (requester as Record<string, unknown>).forwarded === true
748
- );
749
- }
750
-
751
- function reliableFullCommand(
752
- details: PromptPermissionDetails,
753
- ): string | undefined {
754
- const payload = promptPayloadRecord(details);
755
- const evidence = payload?.evidence;
756
- if (Array.isArray(evidence)) {
757
- for (const item of evidence) {
758
- if (!item || typeof item !== "object" || Array.isArray(item)) continue;
759
- const entry = item as Record<string, unknown>;
760
- if (entry.label === "full command" && typeof entry.text === "string" &&
761
- entry.text.trim()) {
762
- return entry.text;
763
- }
764
- }
765
- }
766
- if (payload?.kind !== "bash_external_directory") return undefined;
767
- const request = payload.request;
768
- if (!request || typeof request !== "object" || Array.isArray(request)) {
769
- return undefined;
770
- }
771
- const value = (request as Record<string, unknown>).value;
772
- return typeof value === "string" && value.trim() ? value : undefined;
773
- }
774
-
775
729
  function boundaryRequest(
776
730
  ctx: ExtensionContext,
777
731
  details: PromptPermissionDetails,
@@ -1976,7 +1930,7 @@ export function createPiAutoReviewExtension(
1976
1930
  const uiAutoConfirmer = new PermissionUiAutoConfirmer(
1977
1931
  () => config.autoConfirmBoundedAllows,
1978
1932
  );
1979
- const reviewEntryBatcher = new ReviewEntryBatcher(pi);
1933
+ const reviewWidget = new UserReviewWidgetController();
1980
1934
 
1981
1935
  const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
1982
1936
  writeOptionalAuditFile(event);
@@ -2032,6 +1986,7 @@ export function createPiAutoReviewExtension(
2032
1986
  durationMs: execution.summary.durationMs,
2033
1987
  transcript: execution.summary.transcript,
2034
1988
  summary: execution.summary,
1989
+ unavailable: true,
2035
1990
  });
2036
1991
  }
2037
1992
  emitTelemetry(
@@ -2314,7 +2269,7 @@ export function createPiAutoReviewExtension(
2314
2269
  disposeAuthorizer?.();
2315
2270
  disposeAuthorizer = undefined;
2316
2271
  registeredSessionId = undefined;
2317
- reviewEntryBatcher.flushAll();
2272
+ reviewWidget.clear(context ?? ctx);
2318
2273
  disposeBrokerService?.();
2319
2274
  broker?.clear();
2320
2275
  reviewResults.clear();
@@ -2359,18 +2314,8 @@ export function createPiAutoReviewExtension(
2359
2314
  });
2360
2315
 
2361
2316
  pi.events.on("permissions:decision", (event) => {
2362
- reviewEntryBatcher.permissionDecision(event);
2363
- });
2364
-
2365
- pi.on("tool_execution_start", (event, ctx) => {
2366
- reviewEntryBatcher.toolExecutionStarted(
2367
- ctx.sessionManager.getSessionId(),
2368
- event.toolCallId,
2369
- event.args,
2370
- );
2317
+ reviewWidget.permissionDecision(event);
2371
2318
  });
2372
- pi.on("turn_end", () => reviewEntryBatcher.flushAll());
2373
- pi.on("agent_end", () => reviewEntryBatcher.flushAll());
2374
2319
 
2375
2320
  pi.events.on("permissions:ready", (event) => {
2376
2321
  const ready = event && typeof event === "object" && !Array.isArray(event)
@@ -2395,20 +2340,19 @@ export function createPiAutoReviewExtension(
2395
2340
  registeredSessionId = undefined;
2396
2341
  }
2397
2342
 
2398
- void import("@gotgenes/pi-permission-system").then((module) => {
2399
- if (shuttingDown || epoch !== registrationEpoch || !context) return;
2400
- const service = module.getPermissionsService(sessionId) as
2401
- | PermissionsService
2402
- | undefined;
2403
- if (!service) {
2404
- console.error(
2405
- `${EXTENSION_NAME}: permissions service unavailable for session ${sessionId}`,
2406
- );
2407
- return;
2408
- }
2409
- let dispose: (() => void) | undefined;
2410
- try {
2411
- dispose = service.registerAuthorizer(
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(
2412
2356
  EXTENSION_NAME,
2413
2357
  async (details, query, log: AuthorizerLog) => {
2414
2358
  const evidence = normalizePermissionEvidence(details);
@@ -2425,185 +2369,152 @@ export function createPiAutoReviewExtension(
2425
2369
  surface,
2426
2370
  rationale: reason,
2427
2371
  };
2428
- presentUserReview(
2429
- pi,
2430
- context,
2431
- buildUserReviewNotice(unavailable),
2432
- buildUserReviewEntryData(unavailable),
2433
- );
2372
+ notifyUserReview(context, buildUserReviewNotice(unavailable));
2434
2373
  return { kind: "deny", reason };
2435
2374
  }
2436
2375
 
2437
2376
  const request = boundaryRequest(context, details, query);
2438
2377
  const target = reviewTargetFromRequest(request);
2439
- setUserReviewStatus(
2440
- context,
2441
- buildUserReviewStatus(surface, target),
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),
2442
2432
  );
2443
- try {
2444
- const decision = await broker.review(request, {
2445
- sessionId: context.sessionManager.getSessionId(),
2446
- scopeKey: currentTurnScope(context),
2447
- issueGrant: false,
2448
- });
2449
- const result = reviewResults.get(request.id);
2450
- reviewResults.delete(request.id);
2451
- const allowCapped =
2452
- decision.kind === "allow" && boundedRequest(surface);
2453
- const autoConfirmQueued =
2454
- allowCapped &&
2455
- context.mode === "tui" &&
2456
- context.hasUI &&
2457
- uiAutoConfirmer.stage(request.id, surface);
2458
-
2459
- let userOutcome: UserReviewOutcome;
2460
- if (decision.kind === "deny" && decision.circuitBreakerTripped) {
2461
- userOutcome = "circuit_breaker";
2462
- } else if (allowCapped && autoConfirmQueued) {
2463
- userOutcome = "auto_confirm";
2464
- } else if (allowCapped) {
2465
- userOutcome = "needs_confirmation";
2466
- } else if (decision.kind === "allow") {
2467
- userOutcome = "allow";
2468
- } else if (decision.kind === "defer") {
2469
- userOutcome = "defer";
2470
- } else {
2471
- userOutcome = "deny";
2472
- }
2473
- const reviewMeta = userReviewMetaFromResult(result, config.model);
2474
- const noticeInput = {
2475
- outcome: userOutcome,
2476
- surface,
2477
- target,
2478
- rationale: decision.review.rationale,
2479
- recoveryCommand:
2480
- decision.kind === "deny"
2481
- ? decision.recoveryCommand
2482
- : undefined,
2483
- ...reviewMeta,
2484
- };
2485
- const notice = buildUserReviewNotice(noticeInput);
2486
- const entryData = buildUserReviewEntryData(noticeInput);
2487
- const localSessionId = context.sessionManager.getSessionId();
2488
- if (
2489
- context.mode === "tui" &&
2490
- context.hasUI &&
2491
- request.toolCallId &&
2492
- localSessionId &&
2493
- !promptIsForwarded(details)
2494
- ) {
2495
- reviewEntryBatcher.enqueue({
2496
- sessionId: localSessionId,
2497
- toolCallId: request.toolCallId,
2498
- fullCommand: reliableFullCommand(details),
2499
- member: {
2500
- requestId: request.id,
2501
- ...noticeInput,
2502
- },
2503
- notice,
2504
- data: entryData,
2505
- ctx: context,
2506
- });
2507
- } else {
2508
- presentUserReview(pi, context, notice, entryData);
2509
- }
2510
-
2511
- log.review("pi_auto_review_decision", {
2512
- requestId: request.id,
2513
- toolCallId: request.toolCallId,
2514
- surface,
2515
- model: config.model,
2516
- reviewerModel: reviewMeta.model,
2517
- outcome: allowCapped ? "defer" : decision.kind,
2518
- reviewerOutcome: decision.review.outcome,
2519
- riskLevel: decision.review.riskLevel,
2520
- userAuthorization: decision.review.userAuthorization,
2521
- rationale: decision.review.rationale,
2522
- allowCapped,
2523
- autoConfirmQueued,
2524
- userOutcome,
2525
- circuitBreakerTripped:
2526
- decision.kind === "deny"
2527
- ? decision.circuitBreakerTripped
2528
- : false,
2529
- attempts: result?.attempts ?? 0,
2530
- retryErrors: result?.retryErrors ?? [],
2531
- durationMs: result?.durationMs,
2532
- usageAvailability: reviewMeta.usage?.availability,
2533
- usage: reviewMeta.usage,
2534
- transcriptUserCharacters: result?.transcript.userCharacters,
2535
- transcriptToolCharacters: result?.transcript.toolCharacters,
2536
- transcriptRelevantResultCharacters:
2537
- result?.transcript.relevantResultCharacters,
2538
- transcriptTruncated: result?.transcript.truncated,
2539
- command: request.command,
2540
- path: request.path,
2541
- resolvedPath: request.resolvedPath,
2542
- destination: request.destination,
2543
- agentName: request.agentName,
2544
- requesterSessionId: request.requesterSessionId,
2545
- accessIntent: request.accessIntent,
2546
- authorization:
2547
- decision.kind === "allow"
2548
- ? decision.authorization
2549
- : undefined,
2550
- });
2551
2433
 
2552
- if (allowCapped || decision.kind === "defer") {
2553
- return { kind: "defer" };
2554
- }
2555
- if (decision.kind === "allow") return { kind: "allow" };
2556
- const denyInstruction =
2557
- decision.denialSource === "hard-deny"
2558
- ? LOCAL_HARD_DENY_AGENT_INSTRUCTION
2559
- : decision.recoveryCommand === "/auto-review-break-glass"
2560
- ? REVIEWER_CRITICAL_DENY_AGENT_INSTRUCTION
2561
- : decision.recoveryCommand === "/auto-review-approve"
2562
- ? REVIEWER_NONCRITICAL_DENY_AGENT_INSTRUCTION
2563
- : "Automatic policy critically denied this action and break-glass authorization is disabled. Do not retry, rephrase, or circumvent it.";
2564
- return {
2565
- kind: "deny",
2566
- reason: `${decision.review.rationale} ${denyInstruction}`,
2567
- };
2568
- } finally {
2569
- setUserReviewStatus(context, undefined);
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" };
2570
2477
  }
2571
- },
2572
- );
2573
- if (shuttingDown || epoch !== registrationEpoch || !context) {
2574
- dispose();
2575
- return;
2576
- }
2577
- disposeAuthorizer?.();
2578
- disposeAuthorizer = dispose;
2579
- registeredSessionId = sessionId;
2580
- writeOptionalAuditFile({
2581
- type: "authorizer_registered",
2582
- sessionId,
2583
- });
2584
- } catch (error) {
2585
- dispose?.();
2586
- console.error(
2587
- `${EXTENSION_NAME}: authorizer registration failed: ${
2588
- error instanceof Error ? error.message : String(error)
2589
- }`,
2590
- );
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
+ };
2491
+ },
2492
+ );
2493
+ if (shuttingDown || epoch !== registrationEpoch || !context) {
2494
+ dispose();
2495
+ return;
2591
2496
  }
2592
- }).catch((error) => {
2593
- if (shuttingDown || epoch !== registrationEpoch) return;
2497
+ disposeAuthorizer?.();
2498
+ disposeAuthorizer = dispose;
2499
+ registeredSessionId = sessionId;
2500
+ writeOptionalAuditFile({
2501
+ type: "authorizer_registered",
2502
+ sessionId,
2503
+ });
2504
+ } catch (error) {
2505
+ dispose?.();
2594
2506
  console.error(
2595
- `${EXTENSION_NAME}: permission-system import failed: ${
2507
+ `${EXTENSION_NAME}: authorizer registration failed: ${
2596
2508
  error instanceof Error ? error.message : String(error)
2597
2509
  }`,
2598
2510
  );
2599
- });
2511
+ }
2600
2512
  });
2601
2513
 
2602
2514
  pi.on("session_shutdown", () => {
2603
2515
  shuttingDown = true;
2604
2516
  registrationEpoch++;
2605
- setUserReviewStatus(context, undefined);
2606
- reviewEntryBatcher.flushAll();
2517
+ reviewWidget.clear(context);
2607
2518
  disposeAuthorizer?.();
2608
2519
  disposeAuthorizer = undefined;
2609
2520
  registeredSessionId = undefined;
@@ -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 = compactReviewText(target, 100);
71
+ const compact = truncateReviewText(target, 100);
66
72
  return compact || undefined;
67
73
  }
68
74
 
@@ -197,8 +203,8 @@ function reviewTargetRationaleLine(input: {
197
203
  target?: string;
198
204
  rationale?: string;
199
205
  }): string | undefined {
200
- const target = compactReviewText(input.target, 100);
201
- const rationale = compactReviewText(input.rationale, 180);
206
+ const target = truncateReviewText(input.target, 100);
207
+ const rationale = truncateReviewText(input.rationale, 180);
202
208
  if (target && rationale) return `${target} · ${rationale}`;
203
209
  return target || rationale || undefined;
204
210
  }
@@ -400,7 +406,6 @@ export function renderUserReviewQuoteLines(
400
406
  const contentWidth = Math.max(1, width);
401
407
  const verb = outcomeVerb(data.outcome);
402
408
  const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
403
- const numberAnsi = theme.getFgAnsi("success");
404
409
  const rendered: string[] = [];
405
410
  for (const [index, line] of data.lines.entries()) {
406
411
  for (const visual of wrapReviewDisplayText(line, contentWidth)) {
@@ -411,12 +416,6 @@ export function renderUserReviewQuoteLines(
411
416
  `${verbAnsi}${verb}\x1b[0m`,
412
417
  );
413
418
  }
414
- if (index === data.lines.length - 1) {
415
- painted = painted.replace(
416
- /(\d+(?:\.\d+)?[kM]?)(?=\s(?:toks|calls)|ms\b|s\b)/g,
417
- `${numberAnsi}$1\x1b[0m`,
418
- );
419
- }
420
419
  rendered.push(withQuoteStyle(theme, painted));
421
420
  }
422
421
  }
@@ -528,34 +527,6 @@ export function renderUserReviewEntry(
528
527
  };
529
528
  }
530
529
 
531
- function presentUserReviewGroupEntry(
532
- pi: ExtensionAPI | undefined,
533
- ctx: ExtensionContext | undefined,
534
- data: UserReviewGroupEntryData,
535
- ): boolean {
536
- if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
537
- try {
538
- pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
539
- return true;
540
- } catch {
541
- return false;
542
- }
543
- }
544
-
545
- function presentUserReviewEntry(
546
- pi: ExtensionAPI | undefined,
547
- ctx: ExtensionContext | undefined,
548
- data: UserReviewEntryData,
549
- ): boolean {
550
- if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
551
- try {
552
- pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
553
- return true;
554
- } catch {
555
- return false;
556
- }
557
- }
558
-
559
530
  /** Best-effort toast; never throws into the authorizer path. */
560
531
  export function notifyUserReview(
561
532
  ctx: ExtensionContext | undefined,
@@ -569,184 +540,160 @@ export function notifyUserReview(
569
540
  }
570
541
  }
571
542
 
572
- /** TUI structured entry when possible; otherwise a plain notify toast. */
573
- export function presentUserReview(
574
- pi: ExtensionAPI | undefined,
575
- ctx: ExtensionContext | undefined,
576
- notice: UserReviewNotice,
577
- data: UserReviewEntryData,
578
- ): void {
579
- if (presentUserReviewEntry(pi, ctx, data)) return;
580
- notifyUserReview(ctx, notice);
581
- }
582
-
583
- type PendingReviewGroup = {
584
- sessionId: string;
585
- toolCallId: string;
586
- fullCommand?: string;
587
- members: Map<string, {
588
- member: UserReviewGroupMember;
589
- notice: UserReviewNotice;
590
- data: UserReviewEntryData;
591
- }>;
592
- ctx: ExtensionContext;
593
- createdAt: number;
594
- timer: ReturnType<typeof setTimeout>;
543
+ export type UserReviewWidgetData = UserReviewEntryData & {
544
+ phase: "reviewing" | "complete";
595
545
  };
596
546
 
597
- export type ReviewEntryBatcherOptions = {
598
- maxGroups?: number;
599
- maxMembers?: number;
600
- ttlMs?: number;
601
- now?: () => number;
602
- };
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
+ }
603
565
 
604
- /** Buffers only transcript presentation; permission requests remain independent. */
605
- export class ReviewEntryBatcher {
606
- readonly #groups = new Map<string, PendingReviewGroup>();
607
- readonly #maxGroups: number;
608
- readonly #maxMembers: number;
609
- readonly #ttlMs: number;
610
- readonly #now: () => number;
611
-
612
- constructor(
613
- private readonly pi: ExtensionAPI,
614
- options: ReviewEntryBatcherOptions = {},
615
- ) {
616
- this.#maxGroups = options.maxGroups ?? 32;
617
- this.#maxMembers = options.maxMembers ?? 8;
618
- this.#ttlMs = options.ttlMs ?? 60_000;
619
- this.#now = options.now ?? Date.now;
620
- }
566
+ export function buildUserReviewWidgetData(
567
+ input: UserReviewNoticeInput,
568
+ ): UserReviewWidgetData {
569
+ return {
570
+ phase: "complete",
571
+ ...buildUserReviewEntryData(input),
572
+ };
573
+ }
621
574
 
622
- enqueue(input: {
623
- sessionId: string;
624
- toolCallId: string;
625
- fullCommand?: string;
626
- member: UserReviewGroupMember;
627
- notice: UserReviewNotice;
628
- data: UserReviewEntryData;
629
- ctx: ExtensionContext;
630
- }): void {
631
- try {
632
- const key = this.#key(input.sessionId, input.toolCallId);
633
- let group = this.#groups.get(key);
634
- if (group?.members.has(input.member.requestId)) return;
635
- if (group && group.members.size >= this.#maxMembers) {
636
- this.#flushKey(key);
637
- group = undefined;
638
- }
639
- if (!group) {
640
- if (this.#groups.size >= this.#maxGroups) {
641
- const oldest = [...this.#groups.entries()].sort(
642
- (left, right) => left[1].createdAt - right[1].createdAt,
643
- )[0]?.[0];
644
- if (oldest) this.#flushKey(oldest);
645
- }
646
- const timer = setTimeout(() => this.#flushKey(key), this.#ttlMs);
647
- timer.unref?.();
648
- group = {
649
- sessionId: input.sessionId,
650
- toolCallId: input.toolCallId,
651
- fullCommand: input.fullCommand,
652
- members: new Map(),
653
- ctx: input.ctx,
654
- createdAt: this.#now(),
655
- timer,
656
- };
657
- this.#groups.set(key, group);
658
- } else if (!group.fullCommand && input.fullCommand) {
659
- group.fullCommand = input.fullCommand;
660
- }
661
- group.members.set(input.member.requestId, {
662
- member: input.member,
663
- notice: input.notice,
664
- data: input.data,
665
- });
666
- if (input.member.outcome === "deny" ||
667
- input.member.outcome === "circuit_breaker") {
668
- this.#flushKey(key);
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
+ );
669
597
  }
670
- } catch {
671
- presentUserReview(this.pi, input.ctx, input.notice, input.data);
598
+ rendered.push(painted);
672
599
  }
673
600
  }
601
+ return rendered;
602
+ }
603
+
604
+ function setWidget(
605
+ ctx: ExtensionContext | undefined,
606
+ data: UserReviewWidgetData | undefined,
607
+ ): boolean {
608
+ if (!ctx?.hasUI || ctx.mode !== "tui") return false;
609
+ try {
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;
622
+ } catch {
623
+ return false;
624
+ }
625
+ }
674
626
 
675
- toolExecutionStarted(
676
- sessionId: string,
677
- toolCallId: string,
678
- args: unknown,
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,
679
655
  ): void {
680
- const key = this.#key(sessionId, toolCallId);
681
- const group = this.#groups.get(key);
682
- if (!group) return;
683
- if (!group.fullCommand && args && typeof args === "object" &&
684
- !Array.isArray(args) &&
685
- typeof (args as Record<string, unknown>).command === "string") {
686
- group.fullCommand = String((args as Record<string, unknown>).command);
656
+ if (!ctx.hasUI || ctx.mode !== "tui") {
657
+ notifyUserReview(ctx, notice);
658
+ return;
687
659
  }
688
- this.#flushKey(key);
660
+ if (!this.#isCurrent(requestId, generation)) return;
661
+ this.#current = { requestId, generation, ctx, notice, data };
662
+ if (!setWidget(ctx, data)) notifyUserReview(ctx, notice);
689
663
  }
690
664
 
691
665
  permissionDecision(event: unknown): void {
692
666
  if (!event || typeof event !== "object" || Array.isArray(event)) return;
693
667
  const record = event as Record<string, unknown>;
694
- if (typeof record.requestId !== "string" ||
695
- (record.result !== "allow" && record.result !== "deny")) return;
696
- for (const [key, group] of this.#groups) {
697
- const pending = group.members.get(record.requestId);
698
- if (!pending) continue;
699
- pending.member.permissionResult = record.result;
700
- if (record.result === "deny") this.#flushKey(key);
701
- return;
702
- }
703
- }
704
-
705
- flushAll(): void {
706
- for (const key of [...this.#groups.keys()]) this.#flushKey(key);
707
- }
708
-
709
- get pendingGroups(): number {
710
- return this.#groups.size;
711
- }
712
-
713
- #key(sessionId: string, toolCallId: string): string {
714
- return `${sessionId}\u0000${toolCallId}`;
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);
715
686
  }
716
687
 
717
- #flushKey(key: string): void {
718
- const group = this.#groups.get(key);
719
- if (!group) return;
720
- this.#groups.delete(key);
721
- clearTimeout(group.timer);
722
- const pending = [...group.members.values()];
723
- if (pending.length === 0) return;
724
- const members = pending.map((entry) => entry.member);
725
- const data: UserReviewGroupEntryData = {
726
- kind: "group",
727
- type: noticeType(groupOutcome(members)),
728
- sessionId: group.sessionId,
729
- toolCallId: group.toolCallId,
730
- ...(group.fullCommand ? { fullCommand: group.fullCommand } : {}),
731
- members,
732
- };
733
- if (presentUserReviewGroupEntry(this.pi, group.ctx, data)) return;
734
- for (const entry of pending) {
735
- presentUserReview(this.pi, group.ctx, entry.notice, entry.data);
736
- }
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);
737
693
  }
738
- }
739
694
 
740
- /** Best-effort footer status; pass undefined to clear. */
741
- export function setUserReviewStatus(
742
- ctx: ExtensionContext | undefined,
743
- text: string | undefined,
744
- key = USER_REVIEW_STATUS_KEY,
745
- ): void {
746
- if (!ctx?.hasUI) return;
747
- try {
748
- ctx.ui.setStatus?.(key, text);
749
- } catch {
750
- // UI delivery is observational and must not change a decision.
695
+ #isCurrent(requestId: string, generation: number): boolean {
696
+ return this.#current?.requestId === requestId &&
697
+ this.#current.generation === generation;
751
698
  }
752
699
  }