@hank-warren/pi-auto-permissions 0.1.2 → 0.3.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/NOTICE.md +1 -1
- package/README.md +30 -1
- package/config.ts +25 -0
- package/index.ts +65 -23
- package/package.json +3 -1
- package/usage-log.ts +84 -0
- package/widget-status.ts +89 -0
package/NOTICE.md
CHANGED
|
@@ -4,4 +4,4 @@ This package is a fork of [`@ogulcancelik/pi-auto-permissions`](https://github.c
|
|
|
4
4
|
|
|
5
5
|
The original work is Copyright (c) 2026 Can Celik and is used under the MIT License included in [`LICENSE`](LICENSE).
|
|
6
6
|
|
|
7
|
-
Fork-specific changes are Copyright (c) 2026 Hank Warren and are released under the same MIT License. They include the `reviewEvidence.userAnswerTools` dialog-answer evidence feature, proposed upstream in [ogulcancelik/pi-extensions#24](https://github.com/ogulcancelik/pi-extensions/issues/24) / [PR #25](https://github.com/ogulcancelik/pi-extensions/pull/25),
|
|
7
|
+
Fork-specific changes are Copyright (c) 2026 Hank Warren and are released under the same MIT License. They include the `reviewEvidence.userAnswerTools` dialog-answer evidence feature, proposed upstream in [ogulcancelik/pi-extensions#24](https://github.com/ogulcancelik/pi-extensions/issues/24) / [PR #25](https://github.com/ogulcancelik/pi-extensions/pull/25), the prompted-review evaluation log with explicit user feedback, and the single-line animated review status widget.
|
package/README.md
CHANGED
|
@@ -210,9 +210,38 @@ Each version 2 record contains the collected user request, exact command, compac
|
|
|
210
210
|
|
|
211
211
|
Logging is disabled by default. Records can contain sensitive conversation text and shell commands, so keep the file private and out of repositories. Cancelled or interrupted prompts are not labeled or logged.
|
|
212
212
|
|
|
213
|
+
## Reviewer usage sidecar
|
|
214
|
+
|
|
215
|
+
Guardian reviews are model calls made outside the Pi agent loop, so they never appear in the session transcript and are invisible to tools that total usage from session files. Auto Permissions therefore appends one content-free record per completed review:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{"v":1,"id":"3f2b…","ts":"2026-08-11T22:41:03.118Z","source":"auto-permissions","label":"guardian","provider":"anthropic","model":"claude-fable-5","usage":{"input":812,"output":96,"cacheRead":18442,"cacheWrite":0,"reasoning":48,"cost":0.0121}}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The record carries identity, timing, and counters only. It never contains prompts, commands, reviewer evidence, verdicts, or responses, which is what makes it safe to keep on by default:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"usageLog": {
|
|
226
|
+
"enabled": true,
|
|
227
|
+
"path": "./usage.jsonl"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The path defaults to `usage.jsonl` beside the Auto Permissions config and resolves relative to it. The file is created with mode `0600` and rotates to `usage.jsonl.1` once it passes 16 MB, keeping one previous generation. Writing is best effort: a failing sidecar never blocks or changes a permission decision.
|
|
233
|
+
|
|
234
|
+
[`@hank-warren/pi-stats`](../pi-stats) reads `<agent dir>/<extension>/usage.jsonl` sidecars and shows this usage as its own `provider/model (guardian)` row. Set `"enabled": false` to stop recording.
|
|
235
|
+
|
|
213
236
|
## Review display
|
|
214
237
|
|
|
215
|
-
The default UI shows guardian progress in a temporary widget below the editor
|
|
238
|
+
The default UI shows guardian progress as a single animated status line in a temporary widget below the editor:
|
|
239
|
+
|
|
240
|
+
```text
|
|
241
|
+
auto permissions · Git commit · ✶ waiting for openai-codex/gpt-5.6-luna
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
A sparkle spinner (`✶ ✸ ✻ ✽`) cycles while the guardian is reviewing and resolves to `✓ approved`, `↻ revision requested`, `? waiting for your approval`, or `✗ blocked`. The guardian's reason, when present, appears on a dim second line; the command itself is not repeated because it is already visible in the Bash tool box. Configure the widget with:
|
|
216
245
|
|
|
217
246
|
```json
|
|
218
247
|
{
|
package/config.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface AutoPermissionsConfig {
|
|
|
27
27
|
enabled: boolean;
|
|
28
28
|
path: string;
|
|
29
29
|
};
|
|
30
|
+
usageLog: {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
path: string;
|
|
33
|
+
};
|
|
30
34
|
rules: Gate[];
|
|
31
35
|
ui: {
|
|
32
36
|
enabled: boolean;
|
|
@@ -147,6 +151,26 @@ function resolveEvaluationLog(
|
|
|
147
151
|
};
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
function resolveUsageLog(
|
|
155
|
+
raw: Record<string, unknown>,
|
|
156
|
+
configFilePath: string,
|
|
157
|
+
): AutoPermissionsConfig["usageLog"] {
|
|
158
|
+
const defaultPath = resolve(dirname(configFilePath), "usage.jsonl");
|
|
159
|
+
if (raw.usageLog === undefined) return { enabled: true, path: defaultPath };
|
|
160
|
+
if (!raw.usageLog || typeof raw.usageLog !== "object" || Array.isArray(raw.usageLog)) {
|
|
161
|
+
throw new Error("usageLog must be an object");
|
|
162
|
+
}
|
|
163
|
+
const usageLog = raw.usageLog as Record<string, unknown>;
|
|
164
|
+
if (usageLog.enabled !== undefined && typeof usageLog.enabled !== "boolean") {
|
|
165
|
+
throw new Error("usageLog.enabled must be boolean");
|
|
166
|
+
}
|
|
167
|
+
const enabled = usageLog.enabled !== false;
|
|
168
|
+
const configuredPath = optionalString(usageLog.path, "usageLog.path");
|
|
169
|
+
if (!configuredPath) return { enabled, path: defaultPath };
|
|
170
|
+
const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
|
|
171
|
+
return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
|
|
172
|
+
}
|
|
173
|
+
|
|
150
174
|
function resolveUi(raw: Record<string, unknown>): AutoPermissionsConfig["ui"] {
|
|
151
175
|
if (raw.ui === undefined) return { enabled: true, resultDisplayMs: 2500, placement: "widget" };
|
|
152
176
|
if (!raw.ui || typeof raw.ui !== "object" || Array.isArray(raw.ui)) {
|
|
@@ -199,6 +223,7 @@ export function loadAutoPermissionsConfig(path = configPath()): AutoPermissionsC
|
|
|
199
223
|
systemPrompt: resolvePrompt(raw, path),
|
|
200
224
|
reviewEvidence: resolveReviewEvidence(raw),
|
|
201
225
|
evaluationLog: resolveEvaluationLog(raw, path),
|
|
226
|
+
usageLog: resolveUsageLog(raw, path),
|
|
202
227
|
rules: customRules,
|
|
203
228
|
ui: resolveUi(raw),
|
|
204
229
|
};
|
package/index.ts
CHANGED
|
@@ -16,6 +16,14 @@ import {
|
|
|
16
16
|
type PromptEvaluationUserChoice,
|
|
17
17
|
} from "./evaluation-log.js";
|
|
18
18
|
import { findGates, type Gate } from "./gates.js";
|
|
19
|
+
import { appendUsageRecord, buildUsageLogRecord } from "./usage-log.js";
|
|
20
|
+
import {
|
|
21
|
+
reviewFrameIntervalMs,
|
|
22
|
+
reviewStatusFrame,
|
|
23
|
+
reviewStatusLines,
|
|
24
|
+
type ReviewDisplayState,
|
|
25
|
+
type ReviewLinePalette,
|
|
26
|
+
} from "./widget-status.js";
|
|
19
27
|
import {
|
|
20
28
|
buildReviewEnvelope,
|
|
21
29
|
collectReviewEvidence,
|
|
@@ -28,7 +36,6 @@ const WIDGET_KEY = "auto-permissions";
|
|
|
28
36
|
const PROJECT_CONFIG_DIR_NAME = (PiCodingAgent as { CONFIG_DIR_NAME?: string }).CONFIG_DIR_NAME ?? ".pi";
|
|
29
37
|
|
|
30
38
|
type BlockResult = { block: true; reason: string };
|
|
31
|
-
type ReviewDisplayState = "waiting" | "approved" | "revise" | "ask_user" | "blocked";
|
|
32
39
|
type ReviewTarget = { toolName: string; toolCallId?: string };
|
|
33
40
|
type ReviewRow = {
|
|
34
41
|
gate: Gate;
|
|
@@ -203,6 +210,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
203
210
|
let bashRendererRegistered = false;
|
|
204
211
|
let bashRendererSourcePath: string | undefined;
|
|
205
212
|
let clearWidgetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
213
|
+
let widgetAnimTimer: ReturnType<typeof setInterval> | undefined;
|
|
206
214
|
const reviewRows = new Map<string, ReviewRow>();
|
|
207
215
|
const reviewRowInvalidators = new Map<string, () => void>();
|
|
208
216
|
let reviewerLineage: ReviewerLineage | undefined;
|
|
@@ -357,6 +365,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
357
365
|
if (response.stopReason === "error") {
|
|
358
366
|
throw new Error(response.errorMessage ?? "reviewer request failed");
|
|
359
367
|
}
|
|
368
|
+
recordReviewerUsage(config, model, response.usage);
|
|
360
369
|
const verdict = parsePermissionVerdict(assistantText(response.content));
|
|
361
370
|
if (!sessionActive || signal?.aborted || reviewerGeneration !== attemptGeneration) {
|
|
362
371
|
throw new Error("review timed out or was cancelled");
|
|
@@ -379,6 +388,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
379
388
|
}
|
|
380
389
|
}
|
|
381
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Reviewer calls never reach the session transcript, so their usage is invisible to
|
|
393
|
+
* tooling that reads session files. Record content-free counters in a sidecar instead.
|
|
394
|
+
*/
|
|
395
|
+
function recordReviewerUsage(config: AutoPermissionsConfig, model: { provider: string; id: string }, usage: unknown): void {
|
|
396
|
+
if (!config.usageLog.enabled) return;
|
|
397
|
+
try {
|
|
398
|
+
appendUsageRecord(config.usageLog.path, buildUsageLogRecord(model.provider, model.id, usage));
|
|
399
|
+
} catch {
|
|
400
|
+
// Usage accounting is optional and must never block a permission decision.
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
382
404
|
function untrustedMatches(command: string, config: AutoPermissionsConfig): Gate[] {
|
|
383
405
|
return findGates(command, config.rules).filter((gate) => !trustedGroups.has(gate.group));
|
|
384
406
|
}
|
|
@@ -458,6 +480,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
458
480
|
function clearReviewWidget(ctx: ExtensionContext): void {
|
|
459
481
|
if (clearWidgetTimer) clearTimeout(clearWidgetTimer);
|
|
460
482
|
clearWidgetTimer = undefined;
|
|
483
|
+
if (widgetAnimTimer) clearInterval(widgetAnimTimer);
|
|
484
|
+
widgetAnimTimer = undefined;
|
|
461
485
|
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
462
486
|
}
|
|
463
487
|
|
|
@@ -476,7 +500,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
476
500
|
ctx: ExtensionContext,
|
|
477
501
|
config: AutoPermissionsConfig,
|
|
478
502
|
gate: Gate,
|
|
479
|
-
|
|
503
|
+
_command: string,
|
|
480
504
|
target: ReviewTarget,
|
|
481
505
|
state: ReviewDisplayState,
|
|
482
506
|
detail?: string,
|
|
@@ -497,27 +521,45 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
497
521
|
|
|
498
522
|
if (clearWidgetTimer) clearTimeout(clearWidgetTimer);
|
|
499
523
|
clearWidgetTimer = undefined;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
524
|
+
if (widgetAnimTimer) clearInterval(widgetAnimTimer);
|
|
525
|
+
widgetAnimTimer = undefined;
|
|
526
|
+
ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
527
|
+
const palette: ReviewLinePalette = {
|
|
528
|
+
header: (text) => theme.fg("accent", theme.bold(text)),
|
|
529
|
+
muted: (text) => theme.fg("muted", text),
|
|
530
|
+
warning: (text) => theme.fg("warning", text),
|
|
531
|
+
success: (text) => theme.fg("success", text),
|
|
532
|
+
accent: (text) => theme.fg("accent", text),
|
|
533
|
+
error: (text) => theme.fg("error", text),
|
|
534
|
+
};
|
|
535
|
+
let frameIndex = 0;
|
|
536
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
537
|
+
const stopTimer = () => {
|
|
538
|
+
if (timer) clearInterval(timer);
|
|
539
|
+
if (widgetAnimTimer === timer) widgetAnimTimer = undefined;
|
|
540
|
+
timer = undefined;
|
|
541
|
+
};
|
|
542
|
+
const intervalMs = reviewFrameIntervalMs(state);
|
|
543
|
+
if (intervalMs !== undefined) {
|
|
544
|
+
timer = setInterval(() => {
|
|
545
|
+
frameIndex++;
|
|
546
|
+
if (reviewStatusFrame(state, reviewer, frameIndex).done) stopTimer();
|
|
547
|
+
tui.requestRender();
|
|
548
|
+
}, intervalMs);
|
|
549
|
+
timer.unref?.();
|
|
550
|
+
widgetAnimTimer = timer;
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
render(width: number): string[] {
|
|
554
|
+
return reviewStatusLines(state, gate.label, reviewer, frameIndex, detail, palette)
|
|
555
|
+
.map((line) => truncateToWidth(line, Math.max(1, width)));
|
|
556
|
+
},
|
|
557
|
+
invalidate() {},
|
|
558
|
+
dispose() {
|
|
559
|
+
stopTimer();
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}, { placement: "belowEditor" });
|
|
521
563
|
|
|
522
564
|
if (autoClear) {
|
|
523
565
|
clearWidgetTimer = setTimeout(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-auto-permissions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,6 +43,8 @@
|
|
|
43
43
|
"evaluation-log.ts",
|
|
44
44
|
"gates.ts",
|
|
45
45
|
"review.ts",
|
|
46
|
+
"usage-log.ts",
|
|
47
|
+
"widget-status.ts",
|
|
46
48
|
"README.md",
|
|
47
49
|
"LICENSE"
|
|
48
50
|
],
|
package/usage-log.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Rotate once the sidecar grows past this size; one previous generation is kept. */
|
|
6
|
+
export const USAGE_LOG_ROTATE_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export interface UsageLogTotals {
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
cacheRead: number;
|
|
12
|
+
cacheWrite: number;
|
|
13
|
+
reasoning: number;
|
|
14
|
+
cost: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One model call recorded outside the Pi session transcript. The record is deliberately
|
|
19
|
+
* content free: it carries identity, timing, and counters only, never prompts, commands,
|
|
20
|
+
* evidence, or responses.
|
|
21
|
+
*/
|
|
22
|
+
export interface UsageLogRecord {
|
|
23
|
+
v: 1;
|
|
24
|
+
id: string;
|
|
25
|
+
ts: string;
|
|
26
|
+
source: string;
|
|
27
|
+
label: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
model: string;
|
|
30
|
+
usage: UsageLogTotals;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function count(value: unknown): number {
|
|
34
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Normalize a pi-ai usage payload, whose cost is an object with a total. */
|
|
38
|
+
export function usageLogTotals(usage: unknown): UsageLogTotals {
|
|
39
|
+
const record = (usage && typeof usage === "object" ? usage : {}) as Record<string, unknown>;
|
|
40
|
+
const cost = (record.cost && typeof record.cost === "object" ? record.cost : {}) as Record<string, unknown>;
|
|
41
|
+
return {
|
|
42
|
+
input: count(record.input),
|
|
43
|
+
output: count(record.output),
|
|
44
|
+
cacheRead: count(record.cacheRead),
|
|
45
|
+
cacheWrite: count(record.cacheWrite),
|
|
46
|
+
reasoning: count(record.reasoning),
|
|
47
|
+
cost: count(typeof record.cost === "number" ? record.cost : cost.total),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildUsageLogRecord(
|
|
52
|
+
provider: string,
|
|
53
|
+
model: string,
|
|
54
|
+
usage: unknown,
|
|
55
|
+
label = "guardian",
|
|
56
|
+
source = "auto-permissions",
|
|
57
|
+
): UsageLogRecord {
|
|
58
|
+
return {
|
|
59
|
+
v: 1,
|
|
60
|
+
id: randomUUID(),
|
|
61
|
+
ts: new Date().toISOString(),
|
|
62
|
+
source,
|
|
63
|
+
label,
|
|
64
|
+
provider,
|
|
65
|
+
model,
|
|
66
|
+
usage: usageLogTotals(usage),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rotateIfLarge(path: string): void {
|
|
71
|
+
try {
|
|
72
|
+
if (statSync(path).size < USAGE_LOG_ROTATE_BYTES) return;
|
|
73
|
+
renameSync(path, `${path}.1`);
|
|
74
|
+
} catch {
|
|
75
|
+
// A missing or unrotatable sidecar simply keeps appending.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function appendUsageRecord(path: string, record: UsageLogRecord): void {
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
81
|
+
rotateIfLarge(path);
|
|
82
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
83
|
+
chmodSync(path, 0o600);
|
|
84
|
+
}
|
package/widget-status.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the below-editor review status widget: single-line status
|
|
3
|
+
* formatting plus frame progression for the waiting sparkle spinner. Kept
|
|
4
|
+
* free of TUI dependencies so they are unit-testable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type ReviewDisplayState = "waiting" | "approved" | "revise" | "ask_user" | "blocked";
|
|
8
|
+
|
|
9
|
+
export type ReviewStatusTone = "warning" | "success" | "accent" | "error";
|
|
10
|
+
|
|
11
|
+
export interface ReviewStatusFrame {
|
|
12
|
+
glyph: string;
|
|
13
|
+
label: string;
|
|
14
|
+
tone: ReviewStatusTone;
|
|
15
|
+
/** True once no further frames are needed (state is static). */
|
|
16
|
+
done: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const WAITING_FRAMES = ["✶", "✸", "✻", "✽"] as const;
|
|
20
|
+
export const WAITING_FRAME_INTERVAL_MS = 120;
|
|
21
|
+
|
|
22
|
+
export function reviewStatusFrame(
|
|
23
|
+
state: ReviewDisplayState,
|
|
24
|
+
reviewer: string,
|
|
25
|
+
frameIndex: number,
|
|
26
|
+
): ReviewStatusFrame {
|
|
27
|
+
const index = Math.max(0, Math.floor(frameIndex));
|
|
28
|
+
switch (state) {
|
|
29
|
+
case "waiting":
|
|
30
|
+
return {
|
|
31
|
+
glyph: WAITING_FRAMES[index % WAITING_FRAMES.length],
|
|
32
|
+
label: `waiting for ${reviewer}`,
|
|
33
|
+
tone: "warning",
|
|
34
|
+
done: false,
|
|
35
|
+
};
|
|
36
|
+
case "approved":
|
|
37
|
+
return { glyph: "✓", label: "approved", tone: "success", done: true };
|
|
38
|
+
case "revise":
|
|
39
|
+
return { glyph: "↻", label: "revision requested", tone: "warning", done: true };
|
|
40
|
+
case "ask_user":
|
|
41
|
+
return { glyph: "?", label: "waiting for your approval", tone: "accent", done: true };
|
|
42
|
+
case "blocked":
|
|
43
|
+
return { glyph: "✗", label: "blocked", tone: "error", done: true };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function reviewFrameIntervalMs(state: ReviewDisplayState): number | undefined {
|
|
48
|
+
return state === "waiting" ? WAITING_FRAME_INTERVAL_MS : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReviewLinePalette {
|
|
52
|
+
header: (text: string) => string;
|
|
53
|
+
muted: (text: string) => string;
|
|
54
|
+
warning: (text: string) => string;
|
|
55
|
+
success: (text: string) => string;
|
|
56
|
+
accent: (text: string) => string;
|
|
57
|
+
error: (text: string) => string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const PLAIN_PALETTE: ReviewLinePalette = {
|
|
61
|
+
header: (text) => text,
|
|
62
|
+
muted: (text) => text,
|
|
63
|
+
warning: (text) => text,
|
|
64
|
+
success: (text) => text,
|
|
65
|
+
accent: (text) => text,
|
|
66
|
+
error: (text) => text,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render the widget content: one status line, plus a dim detail line only
|
|
71
|
+
* when a guardian reason is present. The command is intentionally omitted —
|
|
72
|
+
* it is already visible in the bash tool box above.
|
|
73
|
+
*/
|
|
74
|
+
export function reviewStatusLines(
|
|
75
|
+
state: ReviewDisplayState,
|
|
76
|
+
gateLabel: string,
|
|
77
|
+
reviewer: string,
|
|
78
|
+
frameIndex: number,
|
|
79
|
+
detail: string | undefined,
|
|
80
|
+
palette: ReviewLinePalette,
|
|
81
|
+
): string[] {
|
|
82
|
+
const frame = reviewStatusFrame(state, reviewer, frameIndex);
|
|
83
|
+
const status = palette[frame.tone](`${frame.glyph} ${frame.label}`);
|
|
84
|
+
const lines = [
|
|
85
|
+
`${palette.header("auto permissions")} ${palette.muted(`· ${gateLabel} ·`)} ${status}`,
|
|
86
|
+
];
|
|
87
|
+
if (detail) lines.push(palette.muted(` ${detail}`));
|
|
88
|
+
return lines;
|
|
89
|
+
}
|