@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 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`, `? waiting for your approval`, or `✗ blocked`. When approval is needed, a static warning-colored `●` precedes the prompt heading so the active prompt remains visually distinct from the transcript. 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:
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
- `${gate.label} — Auto Permissions needs approval\n\n${detail}\n\n${command}`,
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.19.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 with the shared `OptionSelector` through
15
- * `ctx.ui.custom()` (non-overlay, so it swaps into the editor area exactly
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
- title: string,
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(title, values, { signal });
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 selector;
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
- if (rows.length === 0 && !settled) {
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 dim detail line only
88
- * when a guardian reason is present. The command is intentionally omitted —
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