@hank-warren/pi-auto-permissions 0.17.0 → 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/settings-menu.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  DEFAULT_REVIEWER_REASONING_EFFORT,
22
22
  DEFAULT_REVIEWER_TIMEOUT_MS,
23
+ MAX_REVIEW_CONCURRENCY,
23
24
  MAX_REVIEWER_TIMEOUT_MS,
24
25
  MIN_REVIEWER_TIMEOUT_MS,
25
26
  REASONING_EFFORTS,
@@ -32,6 +33,7 @@ const ENABLED_ID = "enabled";
32
33
  const REVIEWER_MODEL_ID = "reviewerModel";
33
34
  const THINKING_LEVEL_ID = "reasoningEffort";
34
35
  const TIMEOUT_ID = "timeoutMs";
36
+ const REVIEW_CONCURRENCY_ID = "reviewConcurrency";
35
37
  const SYSTEM_PROMPT_ID = "systemPrompt";
36
38
  export const RECENT_DENIALS_ID = "recentDenials";
37
39
 
@@ -47,9 +49,20 @@ const NO_REVIEWER_MESSAGE = "Select a reviewer model first";
47
49
  export interface ReviewerSettings {
48
50
  enabled: boolean;
49
51
  reviewer?: ReviewerConfig;
52
+ reviewConcurrency: number;
50
53
  systemPromptSource: SystemPromptSource;
51
54
  }
52
55
 
56
+ const CONCURRENCY_PRESETS = [1, 2, 4, 8, 16];
57
+
58
+ /**
59
+ * The values the parallel-reviews row cycles through. A hand-written value
60
+ * outside the presets is kept in the cycle, so opening the menu never moves it.
61
+ */
62
+ export function concurrencyValues(current: number): string[] {
63
+ return [...new Set([...CONCURRENCY_PRESETS, current])].sort((a, b) => a - b).map(String);
64
+ }
65
+
53
66
  /** The subset of a pi model the menu needs; keeps tests free of pi-ai types. */
54
67
  export interface MenuModel {
55
68
  provider: string;
@@ -177,6 +190,13 @@ export function buildSettingItems(
177
190
  currentValue: formatTimeout(timeoutValue(settings)),
178
191
  ...(submenus.timeout ? { submenu: submenus.timeout } : {}),
179
192
  },
193
+ {
194
+ id: REVIEW_CONCURRENCY_ID,
195
+ label: "Parallel reviews",
196
+ description: "How many guardian reviews may run at once. 1 reviews one command at a time, none ahead of its turn.",
197
+ currentValue: String(settings.reviewConcurrency),
198
+ values: concurrencyValues(settings.reviewConcurrency),
199
+ },
180
200
  {
181
201
  id: SYSTEM_PROMPT_ID,
182
202
  label: "System prompt",
@@ -241,6 +261,15 @@ export function applySettingChange(
241
261
  return { kind: "settings", settings: next };
242
262
  }
243
263
 
264
+ if (id === REVIEW_CONCURRENCY_ID) {
265
+ const reviewConcurrency = Number(value);
266
+ if (!Number.isInteger(reviewConcurrency) || reviewConcurrency < 1 || reviewConcurrency > MAX_REVIEW_CONCURRENCY) {
267
+ return { kind: "error", message: `Parallel reviews must be a whole number from 1 to ${MAX_REVIEW_CONCURRENCY}` };
268
+ }
269
+ if (reviewConcurrency === settings.reviewConcurrency) return { kind: "ignored" };
270
+ return { kind: "settings", settings: { ...settings, reviewConcurrency } };
271
+ }
272
+
244
273
  // The model and timeout submenus commit their own edit and hand back display
245
274
  // text, so there is nothing left to map here.
246
275
  return { kind: "ignored" };
package/widget-status.ts CHANGED
@@ -30,10 +30,10 @@ export function reviewStatusFrame(
30
30
  ): ReviewStatusFrame {
31
31
  const index = Math.max(0, Math.floor(frameIndex));
32
32
  switch (state) {
33
- // Static rather than animated: the row is replaced by `waiting` the moment
34
- // the queue hands over, so a spinner here would promise progress the
35
- // command is not making. It exists so a second guarded command in one turn
36
- // renders *something* instead of a blank gap while it waits its turn.
33
+ // Static rather than animated: the row is replaced the moment the command
34
+ // gets its slot, so a spinner here would promise progress the command is
35
+ // not making. It exists so a guarded command waiting for a review slot or
36
+ // for another command's decision renders *something* instead of a blank gap.
37
37
  case "queued":
38
38
  return {
39
39
  glyph: "⋯",
@@ -61,6 +61,19 @@ export function reviewFrameIntervalMs(state: ReviewDisplayState): number | undef
61
61
  return state === "waiting" ? WAITING_FRAME_INTERVAL_MS : undefined;
62
62
  }
63
63
 
64
+ /** The states a review is still in while it has not settled. */
65
+ export type ActiveReviewState = Extract<ReviewDisplayState, "queued" | "waiting" | "ask_user">;
66
+
67
+ export function isActiveReviewState(state: ReviewDisplayState): state is ActiveReviewState {
68
+ return state === "queued" || state === "waiting" || state === "ask_user";
69
+ }
70
+
71
+ export interface ReviewSummaryCounts {
72
+ waiting: number;
73
+ queued: number;
74
+ askUser: number;
75
+ }
76
+
64
77
  export interface ReviewLinePalette {
65
78
  header: (text: string) => string;
66
79
  muted: (text: string) => string;
@@ -91,3 +104,27 @@ export function reviewStatusLines(
91
104
  if (detail) lines.push(palette.muted(detail));
92
105
  return lines;
93
106
  }
107
+
108
+ /**
109
+ * One line for several unsettled reviews at once. Per-command detail is left
110
+ * out: each command is visible in its own tool box, and an approval prompt
111
+ * carries its own reason.
112
+ */
113
+ export function reviewSummaryLines(
114
+ counts: ReviewSummaryCounts,
115
+ reviewer: string,
116
+ frameIndex: number,
117
+ palette: ReviewLinePalette,
118
+ ): string[] {
119
+ const total = counts.waiting + counts.queued + counts.askUser;
120
+ const parts: string[] = [];
121
+ if (counts.waiting > 0) {
122
+ const frame = reviewStatusFrame("waiting", reviewer, frameIndex);
123
+ parts.push(palette.warning(`${frame.glyph} ${counts.waiting} ${frame.label}`));
124
+ }
125
+ if (counts.queued > 0) parts.push(palette.muted(`⋯ ${counts.queued} queued`));
126
+ if (counts.askUser > 0) parts.push(palette.accent(`? ${counts.askUser} waiting for your approval`));
127
+ return [
128
+ `${palette.header("auto permissions")} ${palette.muted(`· ${total} commands ·`)} ${parts.join(palette.muted(" · "))}`,
129
+ ];
130
+ }