@hank-warren/pi-auto-permissions 0.19.0 → 0.20.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 +6 -0
- package/README.md +2 -2
- package/approval-prompt.ts +171 -0
- package/index.ts +7 -1
- package/package.json +2 -1
- package/prompt-select.ts +13 -7
- package/review-display.ts +2 -1
- package/widget-status.ts +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @hank-warren/pi-auto-permissions
|
|
2
2
|
|
|
3
|
+
## 0.20.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Rework the approval prompt so a long command can no longer push Allow/Block off-screen. The dialog shows the command (cut to a preview past a quarter of the terminal; Ctrl+O opens the full command in a scrolling viewer inside the prompt, which matters for commands a codemode script built, since the transcript shows only the script), then the guardian's note behind a `▌` bar in a fixed strong yellow, then the options, which stay pinned while you scroll the session. The review widget steps aside while the prompt is open instead of repeating it, and a settled result's reason now carries a `▌` bar in the outcome's color.
|
|
8
|
+
|
|
3
9
|
## 0.19.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -243,7 +243,7 @@ The guardian must return one of three decisions:
|
|
|
243
243
|
|
|
244
244
|
- `approve`: execute the command
|
|
245
245
|
- `revise`: block it and tell the main agent what to correct
|
|
246
|
-
- `ask_user`: open an approval prompt (rendered with [`@hank-warren/pi-permission-selector`](../pi-permission-selector)'s `OptionSelector`): numbered options with `1`–`9` hotkeys, Tab to attach a note that is delivered to the agent as a steering user message, Esc to cancel — which blocks the command. In RPC mode, as in a pi-agents child whose prompts the parent shows, the prompt is Pi's plain select dialog instead: Allow or Block, without Tab notes. It waits for the RPC client's answer, so a client that never answers holds the command until the turn is cancelled
|
|
246
|
+
- `ask_user`: open an approval prompt (rendered with [`@hank-warren/pi-permission-selector`](../pi-permission-selector)'s `OptionSelector`): the command, then the guardian's note behind a `▌` bar in a fixed strong yellow (gold on dark themes, dark goldenrod on light ones, regardless of the theme's `warning` color), then numbered options. A command taller than a quarter of the terminal is cut to a preview, and the prompt stays pinned while you scroll the session above. Ctrl+O opens the full command in a scrolling viewer inside the prompt (Shift+↑/↓ or the mouse wheel; PgUp/PgDn outside fullscreen) and closes it again — the only complete copy when a codemode script built the command, since the transcript then shows the script. Numbered options with `1`–`9` hotkeys, Tab to attach a note that is delivered to the agent as a steering user message, Esc to cancel — which blocks the command. In RPC mode, as in a pi-agents child whose prompts the parent shows, the prompt is Pi's plain select dialog instead: Allow or Block, without Tab notes. It waits for the RPC client's answer, so a client that never answers holds the command until the turn is cancelled
|
|
247
247
|
|
|
248
248
|
## Conversation context and caching
|
|
249
249
|
|
|
@@ -403,7 +403,7 @@ The default UI shows guardian progress as a single animated status line in a tem
|
|
|
403
403
|
auto permissions · Git commit · ✶ waiting for openai-codex-auto-permissions/gpt-5.6-luna
|
|
404
404
|
```
|
|
405
405
|
|
|
406
|
-
A sparkle spinner (`✶ ✸ ✻ ✽`) cycles while the guardian is reviewing and resolves to `✓ approved`, `↻ revision requested`,
|
|
406
|
+
A sparkle spinner (`✶ ✸ ✻ ✽`) cycles while the guardian is reviewing and resolves to `✓ approved`, `↻ revision requested`, or `✗ blocked`. When approval is needed, the widget row steps aside for the approval dialog, whose static yellow `●` heading keeps it visually distinct from the transcript; with several commands under review, the summary line still counts the ones `waiting for your approval`. The reason, when present, appears on a second line behind a `▌` bar in the outcome's color; the command itself is not repeated because it is already visible in the Bash tool box. Configure the widget with:
|
|
407
407
|
|
|
408
408
|
```json
|
|
409
409
|
{
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { OptionSelector, SelectorTheme } from "@hank-warren/pi-permission-selector/selector.ts";
|
|
3
|
+
|
|
4
|
+
/** What an approval prompt shows, top to bottom above the options. */
|
|
5
|
+
export interface ApprovalPromptContent {
|
|
6
|
+
header: string;
|
|
7
|
+
command: string;
|
|
8
|
+
/** Who wrote `note`: "Guardian", or "Review failed" for an infrastructure error. */
|
|
9
|
+
noteLabel: string;
|
|
10
|
+
note: string;
|
|
11
|
+
/**
|
|
12
|
+
* True when the transcript shows this exact command (a model-issued call).
|
|
13
|
+
* A call a codemode script built shows only the script there.
|
|
14
|
+
*/
|
|
15
|
+
inTranscript: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ApprovalPromptTheme extends SelectorTheme {
|
|
19
|
+
bold(text: string): string;
|
|
20
|
+
/** Pi 1.x themes report the background they are designed for. */
|
|
21
|
+
readonly appearance?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The slice of pi-tui's mouse event this reads; older pi-tui releases do not export the type. */
|
|
25
|
+
export interface WheelEvent {
|
|
26
|
+
type: string;
|
|
27
|
+
wheelDelta?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MIN_PREVIEW_ROWS = 3;
|
|
31
|
+
/** Rows left for the rest of Pi's input dock when the full command is open. */
|
|
32
|
+
const RESERVED_ROWS = 6;
|
|
33
|
+
const MIN_VIEWER_ROWS = 3;
|
|
34
|
+
/** The note's height while the viewer is open, so a long note cannot crowd the command out. */
|
|
35
|
+
const VIEWER_NOTE_ROWS = 2;
|
|
36
|
+
|
|
37
|
+
// Fixed rather than the theme's `warning`, which some themes make green or dim:
|
|
38
|
+
// the approval note must read as a caution in every theme.
|
|
39
|
+
const NOTE_YELLOW_DARK = "\x1b[38;2;255;215;0m";
|
|
40
|
+
const NOTE_YELLOW_LIGHT = "\x1b[38;2;184;134;11m";
|
|
41
|
+
const FG_RESET = "\x1b[39m";
|
|
42
|
+
|
|
43
|
+
export function noteYellow(theme: Pick<ApprovalPromptTheme, "appearance">): (text: string) => string {
|
|
44
|
+
const color = theme.appearance === "light" ? NOTE_YELLOW_LIGHT : NOTE_YELLOW_DARK;
|
|
45
|
+
return (text) => `${color}${text}${FG_RESET}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Plain-text form for hosts that render the title themselves (RPC `ui.select`). */
|
|
49
|
+
export function approvalPromptText(content: Omit<ApprovalPromptContent, "inTranscript">): string {
|
|
50
|
+
return `${content.header}\n\n${content.command}\n\n${content.noteLabel}: ${content.note}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function wrapBlock(text: string, width: number): string[] {
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
for (const line of text.split("\n")) {
|
|
56
|
+
if (line.length === 0) lines.push("");
|
|
57
|
+
else lines.push(...wrapTextWithAnsi(line, width));
|
|
58
|
+
}
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The approval dialog: header, the command, the reviewer's note, then the
|
|
64
|
+
* options. It stays compact so the session transcript above keeps its room: a
|
|
65
|
+
* long command is cut to a preview. Ctrl+O opens the full command in a
|
|
66
|
+
* scrolling viewer inside the dialog, because the transcript does not always
|
|
67
|
+
* hold it (a codemode script's calls show only the script).
|
|
68
|
+
*/
|
|
69
|
+
export class ApprovalPrompt {
|
|
70
|
+
private expanded = false;
|
|
71
|
+
private scrollTop = 0;
|
|
72
|
+
private maxScroll = 0;
|
|
73
|
+
private viewportRows = 0;
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private readonly opts: {
|
|
77
|
+
content: ApprovalPromptContent;
|
|
78
|
+
selector: OptionSelector;
|
|
79
|
+
theme: ApprovalPromptTheme;
|
|
80
|
+
terminalRows: () => number;
|
|
81
|
+
requestRender: () => void;
|
|
82
|
+
},
|
|
83
|
+
) {}
|
|
84
|
+
|
|
85
|
+
invalidate(): void {
|
|
86
|
+
this.opts.selector.invalidate();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
handleInput(data: string): void {
|
|
90
|
+
if (matchesKey(data, Key.ctrl("o"))) {
|
|
91
|
+
this.expanded = !this.expanded;
|
|
92
|
+
this.scrollTop = 0;
|
|
93
|
+
this.opts.requestRender();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (this.expanded) {
|
|
97
|
+
const page = Math.max(1, this.viewportRows - 1);
|
|
98
|
+
if (matchesKey(data, Key.pageUp)) return this.scrollBy(-page);
|
|
99
|
+
if (matchesKey(data, Key.pageDown)) return this.scrollBy(page);
|
|
100
|
+
if (matchesKey(data, Key.shift("up"))) return this.scrollBy(-1);
|
|
101
|
+
if (matchesKey(data, Key.shift("down"))) return this.scrollBy(1);
|
|
102
|
+
}
|
|
103
|
+
this.opts.selector.handleInput(data);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Scrolls the open viewer; otherwise the wheel falls through to the session. */
|
|
107
|
+
handleMouse(event: WheelEvent): { handled: true } | undefined {
|
|
108
|
+
if (!this.expanded || event.type !== "wheel" || !event.wheelDelta || this.maxScroll === 0) return undefined;
|
|
109
|
+
this.scrollBy(event.wheelDelta);
|
|
110
|
+
return { handled: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private scrollBy(lines: number): void {
|
|
114
|
+
const next = Math.max(0, Math.min(this.maxScroll, this.scrollTop + lines));
|
|
115
|
+
if (next === this.scrollTop) return;
|
|
116
|
+
this.scrollTop = next;
|
|
117
|
+
this.opts.requestRender();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private noteLines(width: number): string[] {
|
|
121
|
+
const { theme, content } = this.opts;
|
|
122
|
+
const yellow = noteYellow(theme);
|
|
123
|
+
const text = `${theme.bold(`${content.noteLabel}:`)} ${content.note}`;
|
|
124
|
+
const inner = Math.max(1, width - 2);
|
|
125
|
+
let lines = wrapBlock(text, inner);
|
|
126
|
+
if (this.expanded && lines.length > VIEWER_NOTE_ROWS) {
|
|
127
|
+
lines = lines.slice(0, VIEWER_NOTE_ROWS);
|
|
128
|
+
// Reflow the last kept row so the ellipsis always fits.
|
|
129
|
+
lines[VIEWER_NOTE_ROWS - 1] = truncateToWidth(`${lines[VIEWER_NOTE_ROWS - 1]} …`, inner, "…");
|
|
130
|
+
}
|
|
131
|
+
return lines.map((line) => `${yellow("▌ ")}${yellow(line)}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private preview(command: string[], width: number): string[] {
|
|
135
|
+
const { theme, content } = this.opts;
|
|
136
|
+
this.maxScroll = 0;
|
|
137
|
+
const cap = Math.max(MIN_PREVIEW_ROWS, Math.floor(this.opts.terminalRows() / 4));
|
|
138
|
+
if (command.length <= cap) return command;
|
|
139
|
+
const shown = cap - 1;
|
|
140
|
+
const where = content.inTranscript ? "ctrl+o view all, or scroll the session above" : "ctrl+o view all";
|
|
141
|
+
const more = `… ${command.length - shown} more lines · ${where}`;
|
|
142
|
+
return [...command.slice(0, shown), theme.fg("dim", truncateToWidth(more, width))];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private viewer(command: string[], width: number, fixedRows: number): string[] {
|
|
146
|
+
const budget = Math.max(MIN_PREVIEW_ROWS + fixedRows, this.opts.terminalRows() - RESERVED_ROWS);
|
|
147
|
+
// One row goes to the status line.
|
|
148
|
+
this.viewportRows = Math.max(MIN_VIEWER_ROWS, budget - fixedRows - 1);
|
|
149
|
+
this.maxScroll = Math.max(0, command.length - this.viewportRows);
|
|
150
|
+
this.scrollTop = Math.min(this.scrollTop, this.maxScroll);
|
|
151
|
+
const last = Math.min(command.length, this.scrollTop + this.viewportRows);
|
|
152
|
+
const status = `── lines ${this.scrollTop + 1}-${last} of ${command.length} · shift+↑↓/wheel scroll · ctrl+o collapse ──`;
|
|
153
|
+
return [...command.slice(this.scrollTop, last), this.opts.theme.fg("dim", truncateToWidth(status, width))];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
render(width: number): string[] {
|
|
157
|
+
const w = Math.max(1, width);
|
|
158
|
+
const { theme, content } = this.opts;
|
|
159
|
+
const header = wrapBlock(`${noteYellow(theme)(theme.bold("●"))} ${content.header}`, w);
|
|
160
|
+
const note = this.noteLines(w);
|
|
161
|
+
const options = this.opts.selector.render(w);
|
|
162
|
+
const command = wrapBlock(content.command, w);
|
|
163
|
+
const fixedRows = header.length + 1 + 1 + note.length + 1 + options.length;
|
|
164
|
+
const commandRows = this.expanded ? this.viewer(command, w, fixedRows) : this.preview(command, w);
|
|
165
|
+
const lines = [...header, "", ...commandRows, "", ...note, "", ...options];
|
|
166
|
+
// A dock too short for the dialog clips from the top, keeping the options
|
|
167
|
+
// visible: Pi's layout scrolls a clipped component to its cursor line.
|
|
168
|
+
lines[lines.length - 1] = `${CURSOR_MARKER}${lines[lines.length - 1]}`;
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
}
|
package/index.ts
CHANGED
|
@@ -445,7 +445,13 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
445
445
|
choice = await promptSelect(
|
|
446
446
|
pi,
|
|
447
447
|
ctx,
|
|
448
|
-
|
|
448
|
+
{
|
|
449
|
+
header: `${gate.label} — Auto Permissions needs approval`,
|
|
450
|
+
command,
|
|
451
|
+
noteLabel: decisionSource === "guardian" ? "Guardian" : "Review failed",
|
|
452
|
+
note: detail,
|
|
453
|
+
inTranscript: scope.target.scriptToolCallId === undefined,
|
|
454
|
+
},
|
|
449
455
|
permissionPromptOptions(config.evaluationLog.enabled),
|
|
450
456
|
promptSignal,
|
|
451
457
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-auto-permissions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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": [
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"widget-status.ts",
|
|
62
62
|
"classify.ts",
|
|
63
63
|
"prompt-select.ts",
|
|
64
|
+
"approval-prompt.ts",
|
|
64
65
|
"review-display.ts",
|
|
65
66
|
"review-scope.ts",
|
|
66
67
|
"session-overrides.ts",
|
package/prompt-select.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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 { ApprovalPrompt, type ApprovalPromptContent, approvalPromptText } from "./approval-prompt.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Tell Herdr this pane is waiting on a human, so a supervising agent can see
|
|
@@ -11,9 +12,9 @@ export function setHerdrBlocked(pi: ExtensionAPI, active: boolean, label?: strin
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Render an approval prompt
|
|
15
|
-
* `ctx.ui.custom()` (non-overlay, so it swaps into
|
|
16
|
-
* where `ctx.ui.select` used to render).
|
|
15
|
+
* Render an approval prompt — an `ApprovalPrompt` wrapping the shared
|
|
16
|
+
* `OptionSelector` — through `ctx.ui.custom()` (non-overlay, so it swaps into
|
|
17
|
+
* the editor area exactly where `ctx.ui.select` used to render).
|
|
17
18
|
*
|
|
18
19
|
* Cancellation parity with `ctx.ui.select` — all of these resolve
|
|
19
20
|
* `undefined`, which every caller must treat as deny/cancel, never allow:
|
|
@@ -33,11 +34,11 @@ export function setHerdrBlocked(pi: ExtensionAPI, active: boolean, label?: strin
|
|
|
33
34
|
export function promptSelect(
|
|
34
35
|
pi: ExtensionAPI,
|
|
35
36
|
ctx: ExtensionContext,
|
|
36
|
-
|
|
37
|
+
content: ApprovalPromptContent,
|
|
37
38
|
values: string[],
|
|
38
39
|
signal: AbortSignal,
|
|
39
40
|
): Promise<string | undefined> {
|
|
40
|
-
if (ctx.mode === "rpc") return ctx.ui.select(
|
|
41
|
+
if (ctx.mode === "rpc") return ctx.ui.select(approvalPromptText(content), values, { signal });
|
|
41
42
|
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
42
43
|
let finished = false;
|
|
43
44
|
const onAbort = () => finish(undefined);
|
|
@@ -48,7 +49,6 @@ export function promptSelect(
|
|
|
48
49
|
done(result);
|
|
49
50
|
};
|
|
50
51
|
const selector = new OptionSelector({
|
|
51
|
-
title: `${theme.fg("warning", theme.bold("●"))} ${title}`,
|
|
52
52
|
options: values.map((value) => ({ value, label: value })),
|
|
53
53
|
allowComment: true,
|
|
54
54
|
theme,
|
|
@@ -69,6 +69,12 @@ export function promptSelect(
|
|
|
69
69
|
// `done` before the factory resolves is safe: pi marks the dialog closed
|
|
70
70
|
// and never mounts the component.
|
|
71
71
|
if (signal.aborted) onAbort();
|
|
72
|
-
return
|
|
72
|
+
return new ApprovalPrompt({
|
|
73
|
+
content,
|
|
74
|
+
selector,
|
|
75
|
+
theme,
|
|
76
|
+
terminalRows: () => tui.terminal.rows,
|
|
77
|
+
requestRender: () => tui.requestRender(),
|
|
78
|
+
});
|
|
73
79
|
});
|
|
74
80
|
}
|
package/review-display.ts
CHANGED
|
@@ -76,7 +76,8 @@ export function createReviewDisplay(deps: { isSessionActive: () => boolean }): R
|
|
|
76
76
|
if (ctx.mode !== "tui") return;
|
|
77
77
|
const rows = [...active.values()];
|
|
78
78
|
const shown = rows.length === 1 ? rows[0] : rows.length === 0 ? settled : undefined;
|
|
79
|
-
|
|
79
|
+
// A lone review waiting on the human is shown by the approval dialog itself.
|
|
80
|
+
if ((rows.length === 0 && !settled) || shown?.state === "ask_user") {
|
|
80
81
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
81
82
|
return;
|
|
82
83
|
}
|
package/widget-status.ts
CHANGED
|
@@ -84,8 +84,9 @@ export interface ReviewLinePalette {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
* Render the widget content: one status line, plus a
|
|
88
|
-
*
|
|
87
|
+
* Render the widget content: one status line, plus a detail line behind a
|
|
88
|
+
* `▌` bar in the outcome's tone (the approval dialog's note style) only when
|
|
89
|
+
* a reason is present. The command is intentionally omitted —
|
|
89
90
|
* it is already visible in the bash tool box above.
|
|
90
91
|
*/
|
|
91
92
|
export function reviewStatusLines(
|
|
@@ -101,7 +102,7 @@ export function reviewStatusLines(
|
|
|
101
102
|
const lines = [
|
|
102
103
|
`${palette.header("auto permissions")} ${palette.muted(`· ${gateLabel} ·`)} ${status}`,
|
|
103
104
|
];
|
|
104
|
-
if (detail) lines.push(palette.muted(detail));
|
|
105
|
+
if (detail) lines.push(`${palette[frame.tone]("▌")} ${palette.muted(detail)}`);
|
|
105
106
|
return lines;
|
|
106
107
|
}
|
|
107
108
|
|