@hank-warren/pi-auto-permissions 0.16.2 → 0.18.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.
@@ -0,0 +1,22 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ /** Rotate once a sidecar grows past this size; one previous generation is kept. */
5
+ export const SIDECAR_ROTATE_BYTES = 16 * 1024 * 1024;
6
+
7
+ function rotateIfLarge(path: string, rotateBytes: number): void {
8
+ try {
9
+ if (statSync(path).size < rotateBytes) return;
10
+ renameSync(path, `${path}.1`);
11
+ } catch {
12
+ // A missing or unrotatable sidecar simply keeps appending.
13
+ }
14
+ }
15
+
16
+ /** Append one JSON line to an owner-only sidecar, rotating first when `rotateBytes` is set. */
17
+ export function appendJsonlRecord(path: string, record: unknown, rotateBytes?: number): void {
18
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
19
+ if (rotateBytes !== undefined) rotateIfLarge(path, rotateBytes);
20
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
21
+ chmodSync(path, 0o600);
22
+ }
@@ -6,6 +6,8 @@ import type { ReviewEvidenceRecord } from "./review.js";
6
6
  * evidence stream as user-source records so the guardian can treat them as
7
7
  * authorization (allows) or standing constraints (blocks).
8
8
  */
9
+ export const PERMISSION_OVERRIDE_CHOICES = ["allow", "allow_unnecessary", "allow_appropriate", "block"] as const;
10
+
9
11
  export interface PermissionOverride {
10
12
  seq: number;
11
13
  /**
@@ -18,13 +20,7 @@ export interface PermissionOverride {
18
20
  gateLabel: string;
19
21
  command: string;
20
22
  reviewerReason: string;
21
- choice: "allow" | "allow_unnecessary" | "allow_appropriate" | "block";
22
- /** Present only for a user-scoped ledger approval loaded at session start. */
23
- standing?: {
24
- grantedAt: string;
25
- project: string;
26
- gateGroup: string;
27
- };
23
+ choice: (typeof PERMISSION_OVERRIDE_CHOICES)[number];
28
24
  }
29
25
 
30
26
  /** Anchor value meaning "before every collected record". */
@@ -43,16 +39,6 @@ export function overrideEvidenceRecord(override: PermissionOverride): ReviewEvid
43
39
  const command = JSON.stringify(commandPreview(override.command));
44
40
  const gate = JSON.stringify(override.gateLabel);
45
41
  const concern = JSON.stringify(commandPreview(override.reviewerReason));
46
- if (override.standing) {
47
- const granted = override.standing.grantedAt.slice(0, 10);
48
- const project = JSON.stringify(commandPreview(override.standing.project));
49
- const group = JSON.stringify(override.standing.gateGroup);
50
- return {
51
- key: `override:${override.seq}`,
52
- source: "user",
53
- text: `USER (standing permission override, granted ${granted} in ${project}): allowed gated command ${command} (gate ${gate}, group ${group}) despite reviewer concern ${concern}. Treat comparable actions in any project as authorized unless a later user statement or block contradicts this. This never authorizes an action of a materially higher risk class.`,
54
- };
55
- }
56
42
  let text: string;
57
43
  switch (override.choice) {
58
44
  case "allow_unnecessary":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-auto-permissions",
3
- "version": "0.16.2",
3
+ "version": "0.18.0",
4
4
  "description": "Fork of @ogulcancelik/pi-auto-permissions with dialog-answer user evidence: context-aware Bash permissions for Pi with automated guardian review.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -43,24 +43,20 @@
43
43
  "NOTICE.md",
44
44
  "auto-permissions-setup",
45
45
  "index.ts",
46
- "approval-pulse.ts",
47
46
  "config.ts",
48
47
  "config-writer.ts",
49
48
  "default-rules.ts",
50
49
  "denial-log.ts",
51
50
  "settings-menu.ts",
52
51
  "guardian-transport.ts",
53
- "openai-codex-transport.ts",
54
52
  "subagent-context.ts",
55
- "loop-context.ts",
56
- "loop-revise-budget.ts",
57
53
  "evaluation-log.ts",
58
54
  "gates.ts",
55
+ "jsonl-sidecar.ts",
59
56
  "override-evidence.ts",
60
57
  "review.ts",
61
58
  "review-queue.ts",
62
59
  "session-environment.ts",
63
- "standing-overrides.ts",
64
60
  "usage-log.ts",
65
61
  "widget-status.ts",
66
62
  "classify.ts",
@@ -80,7 +76,6 @@
80
76
  "peerDependencies": {
81
77
  "@earendil-works/pi-ai": "*",
82
78
  "@earendil-works/pi-coding-agent": "*",
83
- "@earendil-works/pi-tui": "*",
84
- "typebox": "*"
79
+ "@earendil-works/pi-tui": "*"
85
80
  }
86
81
  }
package/prompt-select.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { OptionSelector } from "@hank-warren/pi-permission-selector/selector.ts";
3
- import { PulsingApprovalIndicator } from "./approval-pulse.js";
4
3
 
5
4
  /**
6
5
  * Tell Herdr this pane is waiting on a human, so a supervising agent can see
@@ -33,23 +32,20 @@ export function promptSelect(
33
32
  title: string,
34
33
  values: string[],
35
34
  signal: AbortSignal,
36
- opts: { allowComment: boolean },
37
35
  ): Promise<string | undefined> {
38
- let indicator: PulsingApprovalIndicator | undefined;
39
36
  return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
40
37
  let finished = false;
41
38
  const onAbort = () => finish(undefined);
42
39
  const finish = (result: string | undefined) => {
43
40
  if (finished) return;
44
41
  finished = true;
45
- indicator?.stop();
46
42
  signal.removeEventListener("abort", onAbort);
47
43
  done(result);
48
44
  };
49
45
  const selector = new OptionSelector({
50
- title,
46
+ title: `${theme.fg("warning", theme.bold("●"))} ${title}`,
51
47
  options: values.map((value) => ({ value, label: value })),
52
- allowComment: opts.allowComment,
48
+ allowComment: true,
53
49
  theme,
54
50
  onSelect: (option, comment) => {
55
51
  if (comment) {
@@ -64,17 +60,10 @@ export function promptSelect(
64
60
  onCancel: () => finish(undefined),
65
61
  requestRender: () => tui.requestRender(),
66
62
  });
67
- indicator = new PulsingApprovalIndicator(
68
- title.split("\n", 1)[0] ?? "Auto Permissions needs approval",
69
- selector,
70
- theme,
71
- () => tui.requestRender(),
72
- );
73
63
  signal.addEventListener("abort", onAbort, { once: true });
74
64
  // `done` before the factory resolves is safe: pi marks the dialog closed
75
65
  // and never mounts the component.
76
66
  if (signal.aborted) onAbort();
77
- else indicator.start();
78
- return indicator;
79
- }).finally(() => indicator?.stop());
67
+ return selector;
68
+ });
80
69
  }
package/review-display.ts CHANGED
@@ -1,267 +1,181 @@
1
- import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
4
- import { readFileSync } from "node:fs";
5
- import { join } from "node:path";
6
- import type { Gate } from "./gates.js";
7
- import type { ReviewScope, ReviewTarget } from "./review-scope.js";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { ReviewScope } from "./review-scope.js";
8
4
  import {
5
+ isActiveReviewState,
9
6
  reviewFrameIntervalMs,
10
- reviewStatusFrame,
11
7
  reviewStatusLines,
8
+ reviewSummaryLines,
9
+ WAITING_FRAME_INTERVAL_MS,
12
10
  type ReviewDisplayState,
13
11
  type ReviewLinePalette,
12
+ type ReviewSummaryCounts,
14
13
  } from "./widget-status.js";
15
14
 
16
- /** The subset of Pi's tool-render theme the review status line uses. */
17
- interface RowTheme {
18
- fg(role: string, text: string): string;
19
- }
20
-
21
15
  const WIDGET_KEY = "auto-permissions";
22
- const PROJECT_CONFIG_DIR_NAME = (PiCodingAgent as { CONFIG_DIR_NAME?: string }).CONFIG_DIR_NAME ?? ".pi";
23
-
24
- type ReviewRow = {
25
- gate: Gate;
26
- state: ReviewDisplayState;
27
- reviewer: string;
28
- detail?: string;
29
- };
30
-
31
- /**
32
- * The status line for one review row.
33
- *
34
- * The tool row words the states that describe *this* call in its own terms
35
- * ("guardian running" where the widget says "waiting for <model>"), because
36
- * the row already sits under the command it belongs to. `queued` is the one
37
- * state that describes the queue rather than the call, so it reuses the
38
- * widget's frame verbatim rather than restating it — two placements
39
- * disagreeing about what "queued" says is exactly what sharing this prevents.
40
- */
41
- function rowStatus(review: ReviewRow, theme: RowTheme): string {
42
- if (review.state === "queued") {
43
- const frame = reviewStatusFrame(review.state, review.reviewer, 0);
44
- return theme.fg(frame.tone, `${frame.glyph} ${frame.label}`);
45
- }
46
- return review.state === "waiting"
47
- ? theme.fg("warning", "◌ guardian running")
48
- : review.state === "approved"
49
- ? theme.fg("success", "✓ approved")
50
- : review.state === "revise"
51
- ? theme.fg("warning", "↻ revision requested")
52
- : review.state === "ask_user"
53
- ? theme.fg("accent", "? approval required")
54
- : theme.fg("error", "✗ blocked");
55
- }
56
16
 
57
17
  export interface ReviewDisplay {
58
- /**
59
- * Take over the `bash` tool so review status renders inside its call row
60
- * instead of above the editor. Only for `ui.placement: "toolRow"`.
61
- */
62
- registerGuardedBash(ctx: ExtensionContext): void;
63
18
  show(scope: ReviewScope, state: ReviewDisplayState, detail?: string, autoClear?: boolean): void;
64
19
  clear(scope: ReviewScope): void;
65
20
  shutdown(ctx: ExtensionContext): void;
66
21
  }
67
22
 
68
- function loadNativeBashOptions(cwd: string, projectTrusted: boolean): { commandPrefix?: string; shellPath?: string } {
69
- const readSettings = (path: string): Record<string, unknown> => {
70
- try {
71
- const value = JSON.parse(readFileSync(path, "utf8"));
72
- return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
73
- } catch {
74
- return {};
75
- }
76
- };
77
- const getAgentDir = (PiCodingAgent as { getAgentDir?: () => string }).getAgentDir;
78
- const global = getAgentDir ? readSettings(join(getAgentDir(), "settings.json")) : {};
79
- const project = projectTrusted ? readSettings(join(cwd, PROJECT_CONFIG_DIR_NAME, "settings.json")) : {};
80
- const setting = (name: string): string | undefined => {
81
- const value = project[name] ?? global[name];
82
- return typeof value === "string" && value.length > 0 ? value : undefined;
83
- };
84
- return { commandPrefix: setting("shellCommandPrefix"), shellPath: setting("shellPath") };
23
+ interface Row {
24
+ scope: ReviewScope;
25
+ state: ReviewDisplayState;
26
+ detail?: string;
27
+ }
28
+
29
+ function rowKey(scope: ReviewScope): string {
30
+ return scope.target.toolCallId ?? `command:${scope.command}`;
31
+ }
32
+
33
+ function reviewerLabel(scope: ReviewScope): string {
34
+ const { ctx, config } = scope;
35
+ return config.reviewer
36
+ ? `${config.reviewer.provider}/${config.reviewer.model}`
37
+ : ctx.model
38
+ ? `${ctx.model.provider}/${ctx.model.id}`
39
+ : "active model";
85
40
  }
86
41
 
87
42
  /**
88
- * Where a review announces itself: the bash tool row when this extension owns
89
- * the renderer, and the above-editor widget otherwise.
43
+ * Where reviews announce themselves: the above-editor widget.
90
44
  *
91
- * Owns the rows, their invalidators, and both widget timers, because every one
92
- * of them is only meaningful next to the others — a row without its
93
- * invalidator never repaints, and a widget timer outliving its widget animates
94
- * something nobody can see.
45
+ * Several guarded commands can be under review at once, so the display keeps
46
+ * one row per unsettled command. One row renders exactly as a lone review
47
+ * always has; several collapse into a single summary line. A settled result
48
+ * is shown only once nothing is left unsettled, and a new review replaces it.
49
+ *
50
+ * Owns both widget timers — the animation interval and the auto-clear
51
+ * timeout — because a timer outliving its widget animates or clears something
52
+ * nobody can see.
95
53
  */
96
- export function createReviewDisplay(
97
- pi: ExtensionAPI,
98
- deps: { isSessionActive: () => boolean },
99
- ): ReviewDisplay {
100
- const reviewRows = new Map<string, ReviewRow>();
101
- const reviewRowInvalidators = new Map<string, () => void>();
102
- let bashRendererRegistered = false;
103
- let bashRendererSourcePath: string | undefined;
54
+ export function createReviewDisplay(deps: { isSessionActive: () => boolean }): ReviewDisplay {
55
+ const active = new Map<string, Row>();
56
+ let settled: Row | undefined;
104
57
  let clearWidgetTimer: ReturnType<typeof setTimeout> | undefined;
105
58
  let widgetAnimTimer: ReturnType<typeof setInterval> | undefined;
106
59
 
107
- function ownsBashRenderer(): boolean {
108
- if (!bashRendererRegistered) return false;
109
- const current = pi.getAllTools().find((tool) => tool.name === "bash");
110
- return bashRendererSourcePath === undefined || current?.sourceInfo.path === bashRendererSourcePath;
60
+ function stopAnimation(): void {
61
+ if (widgetAnimTimer) clearInterval(widgetAnimTimer);
62
+ widgetAnimTimer = undefined;
111
63
  }
112
64
 
113
- function clearReviewWidget(ctx: ExtensionContext): void {
65
+ function stopClearTimer(): void {
114
66
  if (clearWidgetTimer) clearTimeout(clearWidgetTimer);
115
67
  clearWidgetTimer = undefined;
116
- if (widgetAnimTimer) clearInterval(widgetAnimTimer);
117
- widgetAnimTimer = undefined;
118
- if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
119
68
  }
120
69
 
121
- function rowTarget(target: ReviewTarget): string | undefined {
122
- return target.toolName === "bash" && target.toolCallId && ownsBashRenderer()
123
- ? target.toolCallId
124
- : undefined;
70
+ function visible(scope: ReviewScope): boolean {
71
+ return deps.isSessionActive() && scope.ctx.mode === "tui" && scope.config.ui.enabled;
125
72
  }
126
73
 
127
- pi.on("tool_execution_end", async (event) => {
128
- if (event.toolName !== "bash") return;
129
- if (!reviewRows.has(event.toolCallId)) reviewRowInvalidators.delete(event.toolCallId);
130
- });
131
-
132
- return {
133
- registerGuardedBash(ctx: ExtensionContext): void {
134
- if (bashRendererRegistered) return;
135
- const existing = pi.getAllTools().find((tool) => tool.name === "bash");
136
- if (existing && existing.sourceInfo.source !== "builtin") {
137
- ctx.ui.notify("Auto Permissions kept the existing non-native bash backend; review status will use the editor widget.", "warning");
138
- return;
74
+ function render(ctx: ExtensionContext): void {
75
+ stopAnimation();
76
+ if (ctx.mode !== "tui") return;
77
+ const rows = [...active.values()];
78
+ const shown = rows.length === 1 ? rows[0] : rows.length === 0 ? settled : undefined;
79
+ if (rows.length === 0 && !settled) {
80
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
81
+ return;
82
+ }
83
+ const latest = rows.at(-1) ?? settled!;
84
+ const reviewer = reviewerLabel(latest.scope);
85
+ const counts: ReviewSummaryCounts = { waiting: 0, queued: 0, askUser: 0 };
86
+ for (const row of rows) {
87
+ if (row.state === "waiting") counts.waiting += 1;
88
+ else if (row.state === "queued") counts.queued += 1;
89
+ else if (row.state === "ask_user") counts.askUser += 1;
90
+ }
91
+ const intervalMs = shown
92
+ ? reviewFrameIntervalMs(shown.state)
93
+ : counts.waiting > 0 ? WAITING_FRAME_INTERVAL_MS : undefined;
94
+
95
+ ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
96
+ const palette: ReviewLinePalette = {
97
+ header: (text) => theme.fg("accent", theme.bold(text)),
98
+ muted: (text) => theme.fg("muted", text),
99
+ warning: (text) => theme.fg("warning", text),
100
+ success: (text) => theme.fg("success", text),
101
+ accent: (text) => theme.fg("accent", text),
102
+ error: (text) => theme.fg("error", text),
103
+ };
104
+ let frameIndex = 0;
105
+ let timer: ReturnType<typeof setInterval> | undefined;
106
+ const stopTimer = () => {
107
+ if (timer) clearInterval(timer);
108
+ if (widgetAnimTimer === timer) widgetAnimTimer = undefined;
109
+ timer = undefined;
110
+ };
111
+ if (intervalMs !== undefined) {
112
+ timer = setInterval(() => {
113
+ frameIndex++;
114
+ tui.requestRender();
115
+ }, intervalMs);
116
+ timer.unref?.();
117
+ widgetAnimTimer = timer;
139
118
  }
140
-
141
- const createBashToolDefinition = (PiCodingAgent as {
142
- createBashToolDefinition?: (cwd: string, options?: { commandPrefix?: string; shellPath?: string }) => any;
143
- }).createBashToolDefinition;
144
- if (!createBashToolDefinition) return;
145
- const native = createBashToolDefinition(
146
- ctx.cwd,
147
- loadNativeBashOptions(ctx.cwd, ctx.isProjectTrusted()),
148
- );
149
- const nativeRenderCall = native.renderCall;
150
- const { renderResult: _nativeResultRenderer, ...nativeWithoutResultRenderer } = native;
151
-
152
- pi.registerTool({
153
- ...nativeWithoutResultRenderer,
154
- renderCall(args: unknown, theme: any, context: any) {
155
- const state = context.state as Record<string, unknown>;
156
- const base = nativeRenderCall(args, theme, {
157
- ...context,
158
- lastComponent: state.autoPermissionsBaseCallComponent,
159
- });
160
- state.autoPermissionsBaseCallComponent = base;
161
- reviewRowInvalidators.set(context.toolCallId, context.invalidate);
162
-
163
- const container = new Container();
164
- container.addChild(base);
165
- const review = reviewRows.get(context.toolCallId);
166
- if (review) {
167
- const status = rowStatus(review, theme);
168
- const suffix = review.state === "waiting"
169
- ? ` · ${review.gate.label} · ${review.reviewer}`
170
- : ` · ${review.gate.label}`;
171
- let text = `\n ${status}${theme.fg("muted", suffix)}`;
172
- if (review.detail) text += `\n ${theme.fg("muted", review.detail)}`;
173
- container.addChild(new Text(text, 0, 0));
174
- }
175
- return container;
119
+ return {
120
+ render(width: number): string[] {
121
+ const lines = shown
122
+ ? reviewStatusLines(shown.state, shown.scope.gate.label, reviewer, frameIndex, shown.detail, palette)
123
+ : reviewSummaryLines(counts, reviewer, frameIndex, palette);
124
+ return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
176
125
  },
177
- });
178
- bashRendererRegistered = true;
179
- bashRendererSourcePath = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo.path;
180
- },
181
-
182
- show(scope: ReviewScope, state: ReviewDisplayState, detail?: string, autoClear = false): void {
183
- const { ctx, config, gate, target } = scope;
184
- if (!deps.isSessionActive() || ctx.mode !== "tui" || !config.ui.enabled) return;
185
- const reviewer = config.reviewer
186
- ? `${config.reviewer.provider}/${config.reviewer.model}`
187
- : ctx.model
188
- ? `${ctx.model.provider}/${ctx.model.id}`
189
- : "active model";
126
+ invalidate() {},
127
+ dispose() {
128
+ stopTimer();
129
+ },
130
+ };
131
+ }, { placement: "aboveEditor" });
132
+ }
190
133
 
191
- const rowId = rowTarget(target);
192
- if (rowId) {
193
- reviewRows.set(rowId, { gate, state, reviewer, detail });
194
- reviewRowInvalidators.get(rowId)?.();
195
- return;
196
- }
134
+ function reset(ctx: ExtensionContext): void {
135
+ active.clear();
136
+ settled = undefined;
137
+ stopClearTimer();
138
+ stopAnimation();
139
+ if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
140
+ }
197
141
 
198
- if (clearWidgetTimer) clearTimeout(clearWidgetTimer);
199
- clearWidgetTimer = undefined;
200
- if (widgetAnimTimer) clearInterval(widgetAnimTimer);
201
- widgetAnimTimer = undefined;
202
- ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
203
- const palette: ReviewLinePalette = {
204
- header: (text) => theme.fg("accent", theme.bold(text)),
205
- muted: (text) => theme.fg("muted", text),
206
- warning: (text) => theme.fg("warning", text),
207
- success: (text) => theme.fg("success", text),
208
- accent: (text) => theme.fg("accent", text),
209
- error: (text) => theme.fg("error", text),
210
- };
211
- let frameIndex = 0;
212
- let timer: ReturnType<typeof setInterval> | undefined;
213
- const stopTimer = () => {
214
- if (timer) clearInterval(timer);
215
- if (widgetAnimTimer === timer) widgetAnimTimer = undefined;
216
- timer = undefined;
217
- };
218
- const intervalMs = reviewFrameIntervalMs(state);
219
- if (intervalMs !== undefined) {
220
- timer = setInterval(() => {
221
- frameIndex++;
222
- if (reviewStatusFrame(state, reviewer, frameIndex).done) stopTimer();
223
- tui.requestRender();
224
- }, intervalMs);
225
- timer.unref?.();
226
- widgetAnimTimer = timer;
142
+ return {
143
+ show(scope: ReviewScope, state: ReviewDisplayState, detail?: string, autoClear = false): void {
144
+ if (!visible(scope)) return;
145
+ const key = rowKey(scope);
146
+ stopClearTimer();
147
+ if (isActiveReviewState(state)) {
148
+ active.set(key, { scope, state, detail });
149
+ settled = undefined;
150
+ } else {
151
+ active.delete(key);
152
+ const row: Row = { scope, state, detail };
153
+ settled = row;
154
+ if (autoClear) {
155
+ clearWidgetTimer = setTimeout(() => {
156
+ clearWidgetTimer = undefined;
157
+ if (!deps.isSessionActive() || settled !== row) return;
158
+ settled = undefined;
159
+ render(scope.ctx);
160
+ }, scope.config.ui.resultDisplayMs);
161
+ clearWidgetTimer.unref?.();
227
162
  }
228
- return {
229
- render(width: number): string[] {
230
- return reviewStatusLines(state, gate.label, reviewer, frameIndex, detail, palette)
231
- .map((line) => truncateToWidth(line, Math.max(1, width)));
232
- },
233
- invalidate() {},
234
- dispose() {
235
- stopTimer();
236
- },
237
- };
238
- }, { placement: "aboveEditor" });
239
-
240
- if (autoClear) {
241
- clearWidgetTimer = setTimeout(() => {
242
- if (deps.isSessionActive()) clearReviewWidget(ctx);
243
- }, config.ui.resultDisplayMs);
244
- clearWidgetTimer.unref?.();
245
163
  }
164
+ render(scope.ctx);
246
165
  },
247
166
 
248
167
  clear(scope: ReviewScope): void {
249
- const rowId = rowTarget(scope.target);
250
- if (rowId) {
251
- reviewRows.delete(rowId);
252
- const invalidate = reviewRowInvalidators.get(rowId);
253
- invalidate?.();
254
- reviewRowInvalidators.delete(rowId);
255
- return;
168
+ const key = rowKey(scope);
169
+ const wasShown = active.delete(key) || (settled !== undefined && rowKey(settled.scope) === key);
170
+ if (settled && rowKey(settled.scope) === key) {
171
+ settled = undefined;
172
+ stopClearTimer();
256
173
  }
257
- clearReviewWidget(scope.ctx);
174
+ if (wasShown || (active.size === 0 && !settled)) render(scope.ctx);
258
175
  },
259
176
 
260
177
  shutdown(ctx: ExtensionContext): void {
261
- reviewRows.clear();
262
- for (const invalidate of reviewRowInvalidators.values()) invalidate();
263
- reviewRowInvalidators.clear();
264
- clearReviewWidget(ctx);
178
+ reset(ctx);
265
179
  },
266
180
  };
267
181
  }