@gotgenes/pi-permission-system 26.2.0 → 26.2.1

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
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** accept pasted text in the denial-reason field ([8b33c38](https://github.com/gotgenes/pi-packages/commit/8b33c38084689dbb356fa0a0b7069bc610140736)), closes [#760](https://github.com/gotgenes/pi-packages/issues/760)
14
+
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** document the delegated denial-reason editor ([e7329f5](https://github.com/gotgenes/pi-packages/commit/e7329f5e81fc66e5317e958c9928262aaf433173)), closes [#760](https://github.com/gotgenes/pi-packages/issues/760)
19
+
8
20
  ## [26.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.1.0...pi-permission-system-v26.2.0) (2026-08-17)
9
21
 
10
22
 
@@ -135,6 +135,10 @@ It expands both the prompt itself — to the complete request, unbounded by `pro
135
135
  It only toggles the display — it never resolves, commits, or arms the pending decision.
136
136
  While you are typing a denial reason it is not intercepted, so a rebound printable key still reaches the reason editor.
137
137
 
138
+ The reason editor is Pi's own line editor, so it behaves like the chat input: pasting works, as do cursor movement, word and line deletion, the kill ring, and undo.
139
+ The reason is a single line — a pasted line break becomes a space, and a long reason scrolls sideways rather than growing the dialog.
140
+ `enter` submits it, and `esc` (or `Ctrl+C`) returns to the decision list without denying.
141
+
138
142
  ### What a prompt shows
139
143
 
140
144
  The prompt renders one fact per line, with the requesting agent (and, for a forwarded subagent ask, its session), the tool, the gate surface, the matched rule, the decision-relevant value, and — for a wrapper such as `xargs` — the command that will actually run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "26.2.0",
3
+ "version": "26.2.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Bracketed-paste normalization for the inline permission dialog's reason field.
3
+ *
4
+ * A terminal in bracketed-paste mode wraps pasted text in these markers, and
5
+ * the TUI hands the wrapped chunk to the focused component in a single call.
6
+ */
7
+
8
+ const PASTE_START = "\u001b[200~";
9
+ const PASTE_END = "\u001b[201~";
10
+ const NEWLINE_RUN = /[\r\n]+/g;
11
+
12
+ /**
13
+ * Collapse newline runs inside a bracketed-paste chunk to single spaces.
14
+ *
15
+ * The framework line editor deletes newlines outright, which joins the words
16
+ * on either side of a line break; a reason pasted from a multi-line source
17
+ * should stay readable in the single-line field. The markers are preserved so
18
+ * the editor still recognizes the chunk as a paste, and anything that is not
19
+ * a complete paste chunk is returned unchanged.
20
+ */
21
+ export function collapsePastedNewlines(data: string): string {
22
+ const start = data.indexOf(PASTE_START);
23
+ if (start === -1) {
24
+ return data;
25
+ }
26
+ const contentStart = start + PASTE_START.length;
27
+ const contentEnd = data.indexOf(PASTE_END, contentStart);
28
+ if (contentEnd === -1) {
29
+ return data;
30
+ }
31
+ const content = data
32
+ .slice(contentStart, contentEnd)
33
+ .replace(NEWLINE_RUN, " ");
34
+ return data.slice(0, contentStart) + content + data.slice(contentEnd);
35
+ }
@@ -3,7 +3,8 @@ import type {
3
3
  ExtensionUIContext,
4
4
  KeybindingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { type Component, matchesKey } from "@earendil-works/pi-tui";
6
+ import { type Component, Input, matchesKey } from "@earendil-works/pi-tui";
7
+ import { collapsePastedNewlines } from "#src/authority/bracketed-paste";
7
8
  import type {
8
9
  DecisionSource,
9
10
  UserDecisionSurface,
@@ -188,7 +189,8 @@ function handleToolsExpandAction(
188
189
 
189
190
  class PermissionPromptComponent implements Component {
190
191
  private state: PromptViewState;
191
- private reasonBuffer = "";
192
+ /** The denial-reason line editor, rebuilt each time the step is entered. */
193
+ private reason: Input;
192
194
  /** Whether the operator asked to see the complete request (ADR 0011 §4). */
193
195
  private expanded = false;
194
196
 
@@ -203,6 +205,28 @@ class PermissionPromptComponent implements Component {
203
205
  private readonly done: (decision: UnattributedDecision) => void,
204
206
  ) {
205
207
  this.state = initialPromptState(config);
208
+ this.reason = this.createReasonEditor();
209
+ }
210
+
211
+ /**
212
+ * A fresh editor per visit to the reason step.
213
+ *
214
+ * The framework editor carries an undo stack and a kill ring, so reusing one
215
+ * instance would let a reason the operator backed out of be restored into a
216
+ * later ask.
217
+ */
218
+ private createReasonEditor(): Input {
219
+ const editor = new Input();
220
+ // Emits pi-tui's zero-width cursor marker, which positions the hardware
221
+ // cursor for IME composition.
222
+ editor.focused = true;
223
+ editor.onSubmit = (draft) => {
224
+ this.apply({ type: "submitReason", draft });
225
+ };
226
+ editor.onEscape = () => {
227
+ this.apply({ type: "cancel" });
228
+ };
229
+ return editor;
206
230
  }
207
231
 
208
232
  invalidate(): void {
@@ -278,25 +302,18 @@ class PermissionPromptComponent implements Component {
278
302
  }
279
303
  }
280
304
 
305
+ /**
306
+ * Hand the keystroke to the framework line editor.
307
+ *
308
+ * Delegating is what makes the field accept a paste: a paste arrives as one
309
+ * multi-character chunk wrapped in bracketed-paste markers, which the editor
310
+ * understands and a per-character reader cannot. Submit and cancel come back
311
+ * through the editor's callbacks, so the decision model still owns them.
312
+ */
281
313
  private handleReasonInput(data: string): void {
282
- if (matchesKey(data, "enter")) {
283
- this.apply({ type: "submitReason", draft: this.reasonBuffer });
284
- return;
285
- }
286
- if (matchesKey(data, "escape")) {
287
- this.reasonBuffer = "";
288
- this.apply({ type: "cancel" });
289
- return;
290
- }
291
- if (matchesKey(data, "backspace")) {
292
- this.reasonBuffer = this.reasonBuffer.slice(0, -1);
293
- this.requestRender();
294
- return;
295
- }
296
- if (isPrintable(data)) {
297
- this.reasonBuffer += data;
298
- this.requestRender();
299
- }
314
+ this.reason.handleInput(collapsePastedNewlines(data));
315
+ // The editor mutates its own buffer silently; only the dialog can repaint.
316
+ this.requestRender();
300
317
  }
301
318
 
302
319
  private toEvent(data: string): PromptEvent | undefined {
@@ -328,7 +345,7 @@ class PermissionPromptComponent implements Component {
328
345
  return;
329
346
  }
330
347
  if (outcome.state.step === "reason" && this.state.step !== "reason") {
331
- this.reasonBuffer = "";
348
+ this.reason = this.createReasonEditor();
332
349
  }
333
350
  this.state = outcome.state;
334
351
  this.requestRender();
@@ -354,7 +371,9 @@ class PermissionPromptComponent implements Component {
354
371
  this.theme.fg("accent", this.title),
355
372
  ...this.renderAsk(width).lines,
356
373
  "",
357
- `Reason (required): ${this.reasonBuffer}\u2588`,
374
+ "Reason (required):",
375
+ // Exactly one row, whatever its length: the editor scrolls horizontally.
376
+ ...this.reason.render(width),
358
377
  ];
359
378
  if (this.state.reasonError) {
360
379
  lines.push(this.theme.fg("error", this.state.reasonError));
@@ -388,11 +407,3 @@ class PermissionPromptComponent implements Component {
388
407
  return lines;
389
408
  }
390
409
  }
391
-
392
- function isPrintable(data: string): boolean {
393
- if (data.length !== 1) {
394
- return false;
395
- }
396
- const code = data.charCodeAt(0);
397
- return code >= 0x20 && code !== 0x7f;
398
- }
@@ -52,7 +52,6 @@ export interface PromptViewState {
52
52
  armedKey?: PromptKey;
53
53
  /** "Press y again to approve." while armed; empty otherwise. */
54
54
  hint: string;
55
- reasonDraft: string;
56
55
  /** Set when an empty reason submit is rejected. */
57
56
  reasonError?: string;
58
57
  /** Scope step: false = subagent-only (default), true = whole serving session. */
@@ -80,7 +79,6 @@ export function initialPromptState(
80
79
  highlightedKey: "y",
81
80
  armedKey: undefined,
82
81
  hint: "",
83
- reasonDraft: "",
84
82
  reasonError: undefined,
85
83
  scopeServing: false,
86
84
  };
@@ -169,7 +167,6 @@ function commit(
169
167
  highlightedKey: "r",
170
168
  armedKey: undefined,
171
169
  hint: "",
172
- reasonDraft: "",
173
170
  reasonError: undefined,
174
171
  });
175
172
  case "s":
@@ -200,7 +197,6 @@ function reduceReasonStep(
200
197
  step: "decision",
201
198
  armedKey: undefined,
202
199
  hint: "",
203
- reasonDraft: "",
204
200
  reasonError: undefined,
205
201
  });
206
202
  }
@@ -209,7 +205,6 @@ function reduceReasonStep(
209
205
  if (reason === undefined) {
210
206
  return render({
211
207
  ...state,
212
- reasonDraft: event.draft,
213
208
  reasonError: "A reason is required.",
214
209
  });
215
210
  }