@erichll/pi-auto-review 0.8.1 → 0.8.2

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 - 2026-08-21
4
+
5
+ - Structure interactive review results as headline, target, rationale, and a
6
+ meta line with the reviewer model name, input/output tokens, duration, and extra
7
+ calls. The TUI renders them as markdown-style quote rows (`│` + `mdQuote`),
8
+ with allow/deny verbs and token counts tinted from the theme. The model id is
9
+ shown without a provider prefix; audit still records the full `provider/id`.
10
+ Local hard denies omit model and token fields because no model ran.
11
+
3
12
  ## 0.8.1 - 2026-08-21
4
13
 
5
14
  - Restore TUI auto-confirm for capped `path` / `external_directory` allows on
package/README.md CHANGED
@@ -133,8 +133,12 @@ only a fixed, budget-checked schema correction.
133
133
  ## Operator feedback and exact retry
134
134
 
135
135
  Interactive sessions show a best-effort footer while review is running and a
136
- short result toast for allow, local confirmation, defer, deny, or circuit
137
- breaker outcomes. UI delivery never changes the authorization result.
136
+ structured result row for allow, local confirmation, defer, deny, or circuit
137
+ breaker outcomes. In the TUI this is a quote-style entry (`│`, muted quote
138
+ color) that separates the outcome and surface from the target and rationale,
139
+ then shows the reviewer model name (without provider prefix), input/output
140
+ tokens, and duration when a model call ran. UI delivery never changes the
141
+ authorization result.
138
142
 
139
143
  In an interactive TUI, `/auto-review-approve` lists up to ten recent
140
144
  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.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Model-backed boundary approval broker for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -37,12 +37,17 @@ import {
37
37
  } from "./broker/index.ts";
38
38
  import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
39
39
  import {
40
+ buildUserReviewEntryData,
40
41
  buildUserReviewNotice,
41
42
  buildUserReviewStatus,
42
43
  notifyUserReview,
44
+ presentUserReview,
45
+ renderUserReviewEntry,
43
46
  reviewTargetFromRequest,
44
47
  setUserReviewStatus,
48
+ USER_REVIEW_ENTRY_TYPE,
45
49
  type UserReviewOutcome,
50
+ type UserReviewUsage,
46
51
  } from "./user-feedback.ts";
47
52
 
48
53
  export {
@@ -1330,6 +1335,33 @@ function aggregateUsage(attempts: readonly ReviewAttemptObservation[]): {
1330
1335
  return { availability, usage };
1331
1336
  }
1332
1337
 
1338
+ function userReviewMetaFromResult(
1339
+ result: ReviewResult | undefined,
1340
+ fallbackModel: string,
1341
+ ): {
1342
+ model?: string;
1343
+ usage?: UserReviewUsage;
1344
+ durationMs?: number;
1345
+ attempts?: number;
1346
+ } {
1347
+ if (!result) return {};
1348
+ const attempts = result.summary.attempts;
1349
+ const aggregate = aggregateUsage(attempts);
1350
+ return {
1351
+ ...(attempts.length > 0
1352
+ ? {
1353
+ model: attempts.at(-1)?.model ?? fallbackModel,
1354
+ usage: {
1355
+ availability: aggregate.availability,
1356
+ ...aggregate.usage,
1357
+ },
1358
+ }
1359
+ : {}),
1360
+ durationMs: result.durationMs,
1361
+ attempts: result.attempts,
1362
+ };
1363
+ }
1364
+
1333
1365
  function completeTelemetry(
1334
1366
  request: BoundaryRequest,
1335
1367
  config: Readonly<Config>,
@@ -1885,6 +1917,11 @@ export function createPiAutoReviewExtension(
1885
1917
  process.env.PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV === "1";
1886
1918
 
1887
1919
  return (pi: ExtensionAPI): void => {
1920
+ try {
1921
+ pi.registerEntryRenderer(USER_REVIEW_ENTRY_TYPE, renderUserReviewEntry);
1922
+ } catch {
1923
+ // Renderer registration is observational.
1924
+ }
1888
1925
  let context: ExtensionContext | undefined;
1889
1926
  let config: Readonly<Config> = trustedConfig;
1890
1927
  let disposeAuthorizer: (() => void) | undefined;
@@ -1938,6 +1975,23 @@ export function createPiAutoReviewExtension(
1938
1975
  const execution = error instanceof ReviewExecutionError
1939
1976
  ? error
1940
1977
  : new ReviewExecutionError("unknown", noModelSummary());
1978
+ if (request.source === "permission-system") {
1979
+ reviewResults.set(request.id, {
1980
+ decision: {
1981
+ outcome: config.failureMode,
1982
+ risk_level: "high",
1983
+ user_authorization: "unknown",
1984
+ rationale: "Automatic review is unavailable.",
1985
+ },
1986
+ attempts: execution.summary.attempts.length,
1987
+ retryErrors: execution.summary.attempts
1988
+ .map((attempt) => attempt.errorClass)
1989
+ .filter((errorClass) => errorClass !== "none"),
1990
+ durationMs: execution.summary.durationMs,
1991
+ transcript: execution.summary.transcript,
1992
+ summary: execution.summary,
1993
+ });
1994
+ }
1941
1995
  emitTelemetry(
1942
1996
  completeTelemetry(
1943
1997
  request,
@@ -2263,13 +2317,16 @@ export function createPiAutoReviewExtension(
2263
2317
  surface,
2264
2318
  reason,
2265
2319
  });
2266
- notifyUserReview(
2320
+ const unavailable = {
2321
+ outcome: "unavailable" as const,
2322
+ surface,
2323
+ rationale: reason,
2324
+ };
2325
+ presentUserReview(
2326
+ pi,
2267
2327
  context,
2268
- buildUserReviewNotice({
2269
- outcome: "unavailable",
2270
- surface,
2271
- rationale: reason,
2272
- }),
2328
+ buildUserReviewNotice(unavailable),
2329
+ buildUserReviewEntryData(unavailable),
2273
2330
  );
2274
2331
  return { kind: "deny", reason };
2275
2332
  }
@@ -2310,24 +2367,30 @@ export function createPiAutoReviewExtension(
2310
2367
  } else {
2311
2368
  userOutcome = "deny";
2312
2369
  }
2313
- notifyUserReview(
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,
2314
2384
  context,
2315
- buildUserReviewNotice({
2316
- outcome: userOutcome,
2317
- surface,
2318
- target,
2319
- rationale: decision.review.rationale,
2320
- recoveryCommand:
2321
- decision.kind === "deny"
2322
- ? decision.recoveryCommand
2323
- : undefined,
2324
- }),
2385
+ buildUserReviewNotice(noticeInput),
2386
+ buildUserReviewEntryData(noticeInput),
2325
2387
  );
2326
2388
 
2327
2389
  log.review("pi_auto_review_decision", {
2328
2390
  requestId: request.id,
2329
2391
  surface,
2330
2392
  model: config.model,
2393
+ reviewerModel: reviewMeta.model,
2331
2394
  outcome: allowCapped ? "defer" : decision.kind,
2332
2395
  reviewerOutcome: decision.review.outcome,
2333
2396
  riskLevel: decision.review.riskLevel,
@@ -2343,6 +2406,8 @@ export function createPiAutoReviewExtension(
2343
2406
  attempts: result?.attempts ?? 0,
2344
2407
  retryErrors: result?.retryErrors ?? [],
2345
2408
  durationMs: result?.durationMs,
2409
+ usageAvailability: reviewMeta.usage?.availability,
2410
+ usage: reviewMeta.usage,
2346
2411
  transcriptUserCharacters: result?.transcript.userCharacters,
2347
2412
  transcriptToolCharacters: result?.transcript.toolCharacters,
2348
2413
  transcriptRelevantResultCharacters:
@@ -1,6 +1,10 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
 
3
6
  export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
7
+ export const USER_REVIEW_ENTRY_TYPE = "pi-auto-review";
4
8
 
5
9
  export type UserReviewOutcome =
6
10
  | "allow"
@@ -11,6 +15,23 @@ export type UserReviewOutcome =
11
15
  | "circuit_breaker"
12
16
  | "unavailable";
13
17
 
18
+ export type UserReviewUsageAvailability =
19
+ | "reported"
20
+ | "estimated"
21
+ | "unavailable"
22
+ | "unknown_provenance";
23
+
24
+ export type UserReviewUsage = {
25
+ availability: UserReviewUsageAvailability;
26
+ input?: number;
27
+ output?: number;
28
+ cacheRead?: number;
29
+ cacheWrite?: number;
30
+ reasoning?: number;
31
+ totalTokens?: number;
32
+ observedInputTokens?: number;
33
+ };
34
+
14
35
  export type UserReviewNotice = {
15
36
  message: string;
16
37
  type: "info" | "warning" | "error";
@@ -41,7 +62,7 @@ export function reviewTargetFromRequest(request: {
41
62
  request.toolName ??
42
63
  request.skillName ??
43
64
  request.operation;
44
- const compact = compactReviewText(target, 90);
65
+ const compact = compactReviewText(target, 100);
45
66
  return compact || undefined;
46
67
  }
47
68
 
@@ -52,70 +73,356 @@ export function buildUserReviewStatus(
52
73
  ): string {
53
74
  const compact = compactReviewText(target, 48);
54
75
  return compact
55
- ? `auto-review: reviewing ${surface} · ${compact}`
56
- : `auto-review: reviewing ${surface}`;
76
+ ? `auto-review · reviewing · ${surface} · ${compact}`
77
+ : `auto-review · reviewing · ${surface}`;
57
78
  }
58
79
 
59
- /**
60
- * User-facing (not agent-facing) notice after a review decision.
61
- * Kept short so TUI toasts remain readable.
62
- */
63
- export function buildUserReviewNotice(input: {
80
+ export function formatReviewTokenCount(count: number): string {
81
+ if (!Number.isFinite(count) || count < 0) return "0";
82
+ if (count < 1000) return String(Math.round(count));
83
+ if (count < 10_000) {
84
+ return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`;
85
+ }
86
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
87
+ return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
88
+ }
89
+
90
+ export function formatReviewDuration(durationMs: number): string | undefined {
91
+ if (!Number.isFinite(durationMs) || durationMs < 0) return undefined;
92
+ if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
93
+ const seconds = durationMs / 1000;
94
+ return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
95
+ }
96
+
97
+ export function formatReviewUsage(usage: UserReviewUsage): string | undefined {
98
+ if (usage.availability === "unavailable") return undefined;
99
+ const parts: string[] = [];
100
+ const input = usage.input ?? usage.observedInputTokens;
101
+ if (input !== undefined) {
102
+ const cache =
103
+ usage.cacheRead && usage.cacheRead > 0
104
+ ? ` (${formatReviewTokenCount(usage.cacheRead)} cache)`
105
+ : "";
106
+ parts.push(`${formatReviewTokenCount(input)} in${cache}`);
107
+ }
108
+ if (usage.output !== undefined) {
109
+ parts.push(`${formatReviewTokenCount(usage.output)} out`);
110
+ }
111
+ if (parts.length === 0 && usage.totalTokens !== undefined) {
112
+ parts.push(`${formatReviewTokenCount(usage.totalTokens)} tok`);
113
+ }
114
+ if (parts.length === 0) return undefined;
115
+ const approx = usage.availability === "estimated" ? "~" : "";
116
+ return `${approx}${parts.join(" · ")}`;
117
+ }
118
+
119
+ /** Display-only model id; drop a leading provider segment when present. */
120
+ export function formatReviewModelName(model: unknown): string | undefined {
121
+ const raw = String(model ?? "")
122
+ .replace(/\s+/g, " ")
123
+ .trim();
124
+ if (!raw) return undefined;
125
+ const slash = raw.indexOf("/");
126
+ const name =
127
+ slash > 0 && slash < raw.length - 1 ? raw.slice(slash + 1) : raw;
128
+ return compactReviewText(name, 42) || undefined;
129
+ }
130
+
131
+ export function formatReviewMeta(input: {
132
+ model?: string;
133
+ usage?: UserReviewUsage;
134
+ durationMs?: number;
135
+ attempts?: number;
136
+ }): string | undefined {
137
+ const parts: string[] = [];
138
+ const model = formatReviewModelName(input.model);
139
+ if (model) parts.push(model);
140
+ if (input.usage) {
141
+ const usage = formatReviewUsage(input.usage);
142
+ if (usage) parts.push(usage);
143
+ }
144
+ const duration = input.durationMs !== undefined
145
+ ? formatReviewDuration(input.durationMs)
146
+ : undefined;
147
+ if (duration) parts.push(duration);
148
+ if (input.attempts && input.attempts > 1) {
149
+ parts.push(`${input.attempts} calls`);
150
+ }
151
+ return parts.length > 0 ? parts.join(" · ") : undefined;
152
+ }
153
+
154
+ function reviewHeadline(outcome: UserReviewOutcome, surface: string): string {
155
+ switch (outcome) {
156
+ case "allow":
157
+ return `Auto-review · allowed · ${surface}`;
158
+ case "auto_confirm":
159
+ return `Auto-review · allowed · auto-confirm · ${surface}`;
160
+ case "needs_confirmation":
161
+ return `Auto-review · allowed · confirm locally · ${surface}`;
162
+ case "defer":
163
+ return `Auto-review · deferred · ${surface}`;
164
+ case "deny":
165
+ return `Auto-review · denied · ${surface}`;
166
+ case "circuit_breaker":
167
+ return `Auto-review · stopped · ${surface}`;
168
+ case "unavailable":
169
+ return `Auto-review · unavailable · ${surface}`;
170
+ }
171
+ }
172
+
173
+ function recoveryLine(
174
+ outcome: UserReviewOutcome,
175
+ recoveryCommand?: "/auto-review-approve" | "/auto-review-break-glass" | false,
176
+ ): string | undefined {
177
+ if (outcome === "circuit_breaker") {
178
+ return "use /auto-review-approve for one exact retry";
179
+ }
180
+ if (outcome !== "deny") return undefined;
181
+ if (recoveryCommand === false) {
182
+ return "local safety denials cannot be overridden";
183
+ }
184
+ if (recoveryCommand) {
185
+ return `use ${recoveryCommand} for one exact retry`;
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ function joinNoticeLines(lines: Array<string | undefined>): string[] {
191
+ return lines
192
+ .map((line) => (typeof line === "string" ? line.trimEnd() : ""))
193
+ .filter((line) => line.length > 0);
194
+ }
195
+
196
+ export type UserReviewNoticeInput = {
64
197
  outcome: UserReviewOutcome;
65
198
  surface: string;
66
199
  target?: string;
67
200
  rationale?: string;
68
201
  recoveryCommand?: "/auto-review-approve" | "/auto-review-break-glass" | false;
69
- }): UserReviewNotice {
70
- const target = compactReviewText(input.target, 70);
71
- const rationale = compactReviewText(input.rationale, 120);
72
- const subject = target ? `${input.surface} · ${target}` : input.surface;
73
- const why = rationale ? ` — ${rationale}` : "";
202
+ model?: string;
203
+ usage?: UserReviewUsage;
204
+ durationMs?: number;
205
+ attempts?: number;
206
+ };
74
207
 
75
- switch (input.outcome) {
208
+ export type UserReviewEntryData = {
209
+ outcome: UserReviewOutcome;
210
+ type: UserReviewNotice["type"];
211
+ lines: string[];
212
+ };
213
+
214
+ type UserReviewTheme = {
215
+ fg(color: string, text: string): string;
216
+ italic(text: string): string;
217
+ getFgAnsi(color: string): string;
218
+ };
219
+
220
+ function noticeType(outcome: UserReviewOutcome): UserReviewNotice["type"] {
221
+ if (outcome === "unavailable") return "error";
222
+ if (outcome === "deny" || outcome === "circuit_breaker") return "warning";
223
+ return "info";
224
+ }
225
+
226
+ function outcomeAccent(
227
+ outcome: UserReviewOutcome,
228
+ ): "success" | "warning" | "error" | "muted" {
229
+ switch (outcome) {
230
+ case "allow":
231
+ case "auto_confirm":
232
+ case "needs_confirmation":
233
+ return "success";
234
+ case "defer":
235
+ return "muted";
236
+ case "deny":
237
+ case "circuit_breaker":
238
+ return "warning";
239
+ case "unavailable":
240
+ return "error";
241
+ }
242
+ }
243
+
244
+ function outcomeVerb(outcome: UserReviewOutcome): string {
245
+ switch (outcome) {
76
246
  case "allow":
77
- return {
78
- type: "info",
79
- message: `Auto-review allowed ${subject}${why}`,
80
- };
81
247
  case "auto_confirm":
82
- return {
83
- type: "info",
84
- message: `Auto-review allowed ${subject}; auto-confirming the local dialog${why}`,
85
- };
86
248
  case "needs_confirmation":
87
- return {
88
- type: "info",
89
- message: `Auto-review allowed ${subject}; local confirmation is still required${why}`,
90
- };
249
+ return "allowed";
91
250
  case "defer":
92
- return {
93
- type: "info",
94
- message: `Auto-review deferred ${subject} to you${why}`,
95
- };
251
+ return "deferred";
96
252
  case "deny":
97
- return {
98
- type: "warning",
99
- message: `Auto-review denied ${subject}${why}${
100
- input.recoveryCommand === false
101
- ? " — local safety denials cannot be overridden"
102
- : input.recoveryCommand
103
- ? ` — use ${input.recoveryCommand} for one exact retry`
104
- : ""
105
- }`,
106
- };
253
+ return "denied";
107
254
  case "circuit_breaker":
108
- return {
109
- type: "warning",
110
- message: `Auto-review stopped after repeated denials this turn${
111
- why || " — use /auto-review-approve for one exact retry"
112
- }`,
113
- };
255
+ return "stopped";
114
256
  case "unavailable":
115
- return {
116
- type: "error",
117
- message: `Auto-review is unavailable${why}`,
118
- };
257
+ return "unavailable";
258
+ }
259
+ }
260
+
261
+ export function buildUserReviewLines(input: UserReviewNoticeInput): string[] {
262
+ return joinNoticeLines([
263
+ reviewHeadline(input.outcome, input.surface),
264
+ compactReviewText(input.target, 100) || undefined,
265
+ compactReviewText(input.rationale, 180) || undefined,
266
+ recoveryLine(input.outcome, input.recoveryCommand),
267
+ formatReviewMeta({
268
+ model: input.model,
269
+ usage: input.usage,
270
+ durationMs: input.durationMs,
271
+ attempts: input.attempts,
272
+ }),
273
+ ]);
274
+ }
275
+
276
+ /**
277
+ * User-facing (not agent-facing) notice after a review decision.
278
+ * Structured for TUI wrapping: headline, target, rationale, then model/tokens.
279
+ */
280
+ export function buildUserReviewNotice(
281
+ input: UserReviewNoticeInput,
282
+ ): UserReviewNotice {
283
+ return {
284
+ type: noticeType(input.outcome),
285
+ message: buildUserReviewLines(input).join("\n"),
286
+ };
287
+ }
288
+
289
+ export function buildUserReviewEntryData(
290
+ input: UserReviewNoticeInput,
291
+ ): UserReviewEntryData {
292
+ return {
293
+ outcome: input.outcome,
294
+ type: noticeType(input.outcome),
295
+ lines: buildUserReviewLines(input),
296
+ };
297
+ }
298
+
299
+ export function formatUserReviewQuoteMessage(message: string): string {
300
+ return message
301
+ .split("\n")
302
+ .map((line) => `│ ${line}`)
303
+ .join("\n");
304
+ }
305
+
306
+ function displayWidth(text: string): number {
307
+ let width = 0;
308
+ for (const char of text) {
309
+ const code = char.codePointAt(0) ?? 0;
310
+ width += code > 0xff ? 2 : 1;
311
+ }
312
+ return width;
313
+ }
314
+
315
+ export function wrapReviewDisplayText(text: string, width: number): string[] {
316
+ if (width <= 0) return [text];
317
+ const lines: string[] = [];
318
+ let current = "";
319
+ let currentWidth = 0;
320
+ const flush = () => {
321
+ if (current.length > 0) lines.push(current);
322
+ current = "";
323
+ currentWidth = 0;
324
+ };
325
+ for (const token of text.split(/(\s+)/)) {
326
+ const tokenWidth = displayWidth(token);
327
+ if (currentWidth + tokenWidth <= width) {
328
+ current += token;
329
+ currentWidth += tokenWidth;
330
+ continue;
331
+ }
332
+ if (/^\s+$/.test(token)) {
333
+ flush();
334
+ continue;
335
+ }
336
+ if (currentWidth > 0) flush();
337
+ if (tokenWidth <= width) {
338
+ current = token;
339
+ currentWidth = tokenWidth;
340
+ continue;
341
+ }
342
+ for (const char of token) {
343
+ const charWidth = (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1;
344
+ if (currentWidth + charWidth > width) flush();
345
+ current += char;
346
+ currentWidth += charWidth;
347
+ }
348
+ }
349
+ flush();
350
+ return lines.length > 0 ? lines : [""];
351
+ }
352
+
353
+ function withQuoteStyle(theme: UserReviewTheme, text: string): string {
354
+ const marker = "\u0000";
355
+ const prefix = theme.italic(theme.fg("mdQuote", marker)).split(marker)[0] ?? "";
356
+ const body = theme.italic(theme.fg("mdQuote", text));
357
+ return prefix ? body.replace(/\x1b\[0m/g, `\x1b[0m${prefix}`) : body;
358
+ }
359
+
360
+ export function renderUserReviewQuoteLines(
361
+ data: UserReviewEntryData,
362
+ theme: UserReviewTheme,
363
+ width: number,
364
+ ): string[] {
365
+ const contentWidth = Math.max(1, width - 2);
366
+ const border = theme.fg("mdQuoteBorder", "│ ");
367
+ const verb = outcomeVerb(data.outcome);
368
+ const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
369
+ const numberAnsi = theme.getFgAnsi("success");
370
+ const rendered: string[] = [];
371
+ for (const [index, line] of data.lines.entries()) {
372
+ for (const visual of wrapReviewDisplayText(line, contentWidth)) {
373
+ let painted = visual;
374
+ if (index === 0) {
375
+ painted = painted.replace(
376
+ verb,
377
+ `${verbAnsi}${verb}\x1b[0m`,
378
+ );
379
+ }
380
+ if (index === data.lines.length - 1) {
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));
387
+ }
388
+ }
389
+ return rendered;
390
+ }
391
+
392
+ export function renderUserReviewEntry(
393
+ entry: { data?: unknown },
394
+ _options: unknown,
395
+ theme: UserReviewTheme,
396
+ ): { render(width: number): string[]; invalidate(): void } | undefined {
397
+ const data = entry.data;
398
+ if (
399
+ !data ||
400
+ typeof data !== "object" ||
401
+ !Array.isArray((data as UserReviewEntryData).lines) ||
402
+ (data as UserReviewEntryData).lines.length === 0
403
+ ) {
404
+ return undefined;
405
+ }
406
+ const parsed = data as UserReviewEntryData;
407
+ return {
408
+ render(width: number) {
409
+ return renderUserReviewQuoteLines(parsed, theme, width);
410
+ },
411
+ invalidate() {},
412
+ };
413
+ }
414
+
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;
119
426
  }
120
427
  }
121
428
 
@@ -126,12 +433,23 @@ export function notifyUserReview(
126
433
  ): void {
127
434
  if (!ctx?.hasUI) return;
128
435
  try {
129
- ctx.ui.notify(notice.message, notice.type);
436
+ ctx.ui.notify(formatUserReviewQuoteMessage(notice.message), notice.type);
130
437
  } catch {
131
438
  // UI delivery is observational and must not change a decision.
132
439
  }
133
440
  }
134
441
 
442
+ /** TUI quote-style entry when possible; otherwise a quoted notify toast. */
443
+ export function presentUserReview(
444
+ pi: ExtensionAPI | undefined,
445
+ ctx: ExtensionContext | undefined,
446
+ notice: UserReviewNotice,
447
+ data: UserReviewEntryData,
448
+ ): void {
449
+ if (presentUserReviewEntry(pi, ctx, data)) return;
450
+ notifyUserReview(ctx, notice);
451
+ }
452
+
135
453
  /** Best-effort footer status; pass undefined to clear. */
136
454
  export function setUserReviewStatus(
137
455
  ctx: ExtensionContext | undefined,