@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.
- package/CHANGELOG.md +39 -0
- package/README.md +52 -74
- package/auto-permissions-setup/SKILL.md +4 -2
- package/classify.ts +7 -26
- package/config-writer.ts +12 -51
- package/config.ts +100 -236
- package/denial-log.ts +5 -22
- package/evaluation-log.ts +8 -51
- package/gates.ts +6 -13
- package/guardian-reviewer.ts +190 -209
- package/index.ts +484 -306
- package/jsonl-sidecar.ts +22 -0
- package/override-evidence.ts +3 -17
- package/package.json +3 -8
- package/prompt-select.ts +4 -15
- package/review-display.ts +134 -220
- package/review-queue.ts +88 -21
- package/review-scope.ts +15 -4
- package/review.ts +136 -68
- package/session-overrides.ts +27 -145
- package/settings-command.ts +11 -48
- package/settings-menu.ts +34 -132
- package/usage-log.ts +3 -20
- package/widget-status.ts +45 -21
- package/approval-pulse.ts +0 -80
- package/loop-context.ts +0 -41
- package/loop-revise-budget.ts +0 -348
- package/openai-codex-transport.ts +0 -9
- package/standing-overrides.ts +0 -266
package/jsonl-sidecar.ts
ADDED
|
@@ -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
|
+
}
|
package/override-evidence.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
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:
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
}).finally(() => indicator?.stop());
|
|
67
|
+
return selector;
|
|
68
|
+
});
|
|
80
69
|
}
|
package/review-display.ts
CHANGED
|
@@ -1,267 +1,181 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
89
|
-
* the renderer, and the above-editor widget otherwise.
|
|
43
|
+
* Where reviews announce themselves: the above-editor widget.
|
|
90
44
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
108
|
-
if (
|
|
109
|
-
|
|
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
|
|
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
|
|
122
|
-
return
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
174
|
+
if (wasShown || (active.size === 0 && !settled)) render(scope.ctx);
|
|
258
175
|
},
|
|
259
176
|
|
|
260
177
|
shutdown(ctx: ExtensionContext): void {
|
|
261
|
-
|
|
262
|
-
for (const invalidate of reviewRowInvalidators.values()) invalidate();
|
|
263
|
-
reviewRowInvalidators.clear();
|
|
264
|
-
clearReviewWidget(ctx);
|
|
178
|
+
reset(ctx);
|
|
265
179
|
},
|
|
266
180
|
};
|
|
267
181
|
}
|