@gotgenes/pi-permission-system 20.6.0 → 20.7.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,23 @@ 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
+ ## [20.7.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.7.0...pi-permission-system-v20.7.1) (2026-07-14)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** clip inline permission prompt lines to terminal width ([e3c6907](https://github.com/gotgenes/pi-packages/commit/e3c6907057070cbb444f4ca0dbc69661849b7dc2)), closes [#573](https://github.com/gotgenes/pi-packages/issues/573)
14
+
15
+ ## [20.7.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.6.0...pi-permission-system-v20.7.0) (2026-07-14)
16
+
17
+
18
+ ### Features
19
+
20
+ * **pi-permission-system:** add doublePressToConfirm config toggle ([bd401bf](https://github.com/gotgenes/pi-packages/commit/bd401bff0bd926313acfafe5e50b579c88e4002f))
21
+ * **pi-permission-system:** add inline permission prompt decision model ([08d6ec6](https://github.com/gotgenes/pi-packages/commit/08d6ec6b7e9abbcbf05b49579d307e3f5934cb3a))
22
+ * **pi-permission-system:** dispatch TUI permission prompts to the inline keybind dialog ([197c0f9](https://github.com/gotgenes/pi-packages/commit/197c0f91178f7010e84ed80eb3682dc27c1f737f))
23
+ * **pi-permission-system:** render inline keybind permission prompt ([7ac7dad](https://github.com/gotgenes/pi-packages/commit/7ac7dad3ef49f03e19b279056ea54d4c5a456c80))
24
+
8
25
  ## [20.6.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.5.0...pi-permission-system-v20.6.0) (2026-07-13)
9
26
 
10
27
 
package/README.md CHANGED
@@ -65,7 +65,8 @@ All permissions use one of three states:
65
65
  | `ask` | Prompts the user for confirmation via UI |
66
66
 
67
67
  When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
68
- See [docs/session-approvals.md](docs/session-approvals.md) for details on session-scoped rules and pattern suggestions.
68
+ In an interactive TUI session the prompt is an inline keybind dialog — `y` approve, `s` approve for this session, `n` deny, `r` deny with a reason — where each hotkey arms and a second press confirms (configurable via `doublePressToConfirm`).
69
+ See [docs/configuration.md](docs/configuration.md#inline-permission-dialog-tui) for the hotkeys and [docs/session-approvals.md](docs/session-approvals.md) for session-scoped rules and pattern suggestions.
69
70
 
70
71
  The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
71
72
  Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow — making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
@@ -4,6 +4,7 @@
4
4
  "debugLog": false,
5
5
  "permissionReviewLog": true,
6
6
  "yoloMode": false,
7
+ "doublePressToConfirm": true,
7
8
 
8
9
  "toolInputPreviewMaxLength": 400,
9
10
  "toolTextSummaryMaxLength": 120,
@@ -30,7 +30,7 @@ Project config overrides global config; per-agent frontmatter overrides both.
30
30
  4. Project agent frontmatter
31
31
 
32
32
  The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
33
- Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replacement.
33
+ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`) use simple replacement.
34
34
 
35
35
  ## Full Example
36
36
 
@@ -42,6 +42,7 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replace
42
42
  "debugLog": false,
43
43
  "permissionReviewLog": true,
44
44
  "yoloMode": false,
45
+ "doublePressToConfirm": true,
45
46
  "toolInputPreviewMaxLength": 400,
46
47
  "toolTextSummaryMaxLength": 120,
47
48
  "piInfrastructureReadPaths": [],
@@ -85,6 +86,7 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replace
85
86
  | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
86
87
  | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
87
88
  | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
89
+ | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
88
90
  | `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
89
91
  | `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
90
92
  | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`). |
@@ -92,6 +94,23 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replace
92
94
  Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
93
95
  No debug output is printed to the terminal.
94
96
 
97
+ ### Inline permission dialog (TUI)
98
+
99
+ In an interactive **TUI** session, an `ask` decision opens an inline keybind dialog with one-key shortcuts:
100
+
101
+ | Key | Action |
102
+ | --- | ----------------------------------------------------------------- |
103
+ | `y` | Approve once |
104
+ | `s` | Approve for this session |
105
+ | `n` | Deny |
106
+ | `r` | Deny with a reason (opens an inline editor; a reason is required) |
107
+
108
+ Arrow keys / `j`/`k` move the highlight, `enter` confirms the highlighted option, and `esc` denies.
109
+ With `doublePressToConfirm` enabled (the default), a letter hotkey **arms** its action and shows a `Press y again to approve.` hint; press the same key again to commit.
110
+ Set `doublePressToConfirm` to `false` to commit on the first press.
111
+
112
+ Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`.
113
+
95
114
  ### `piInfrastructureReadPaths` patterns
96
115
 
97
116
  Each entry is either a plain directory prefix or a wildcard pattern.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.6.0",
3
+ "version": "20.7.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,6 +25,12 @@
25
25
  "default": false,
26
26
  "type": "boolean"
27
27
  },
28
+ "doublePressToConfirm": {
29
+ "description": "Require a confirming second press of a decision hotkey in the inline permission dialog. Applies to TUI sessions only.",
30
+ "markdownDescription": "Require a confirming second press of a decision hotkey (`y`/`s`/`n`/`r`) in the inline permission dialog before it commits — the first press arms the action and shows a `Press y again to approve.` hint.\n\nApplies to interactive **TUI** sessions only; the non-TUI (RPC/frontend) prompt keeps its single-select flow. Set to `false` to commit decisions on the first hotkey press.",
31
+ "default": true,
32
+ "type": "boolean"
33
+ },
28
34
  "toolInputPreviewMaxLength": {
29
35
  "description": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
30
36
  "markdownDescription": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts.\n\nOmit to use the default (200). Set to a large value (e.g. `10000`) to effectively disable truncation and see the full input.",
@@ -1,8 +1,9 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
3
  import type {
3
- PermissionPromptDecision,
4
- requestPermissionDecisionFromUi,
5
- } from "#src/authority/permission-dialog";
4
+ PromptPreferences,
5
+ requestPermissionDecision,
6
+ } from "#src/authority/permission-prompt-component";
6
7
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
7
8
  import type { PermissionEventBus } from "#src/permission-events";
8
9
  import type { DebugReviewLogger } from "#src/session-logger";
@@ -33,8 +34,10 @@ export interface AuthorizerSelectionDeps {
33
34
  detection: SubagentDetector;
34
35
  /** Event bus used by `LocalUserAuthorizer` for the `permissions:ui_prompt` broadcast. */
35
36
  events: PermissionEventBus;
37
+ /** Read live at prompt time; threaded into `LocalUserAuthorizer`. */
38
+ getPromptPreferences: () => PromptPreferences;
36
39
  /** Injected for testability; production callers pass the real function. */
37
- requestPermissionDecisionFromUi: typeof requestPermissionDecisionFromUi;
40
+ requestPermissionDecision: typeof requestPermissionDecision;
38
41
  /** Forwarding directory `ParentAuthorizer` reads/writes request and response files under. */
39
42
  forwardingDir: string;
40
43
  /** In-process subagent session registry for forwarding target resolution. */
@@ -57,8 +60,10 @@ export function selectAuthorizer(
57
60
  if (ctx.hasUI) {
58
61
  return new LocalUserAuthorizer({
59
62
  ui: ctx.ui,
63
+ mode: ctx.mode,
60
64
  events: deps.events,
61
- requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
65
+ getPromptPreferences: deps.getPromptPreferences,
66
+ requestPermissionDecision: deps.requestPermissionDecision,
62
67
  });
63
68
  }
64
69
  if (deps.detection.isSubagent(ctx)) {
@@ -1,9 +1,13 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
2
  import type {
2
- PermissionDecisionUi,
3
3
  PermissionPromptDecision,
4
4
  RequestPermissionOptions,
5
- requestPermissionDecisionFromUi,
6
5
  } from "#src/authority/permission-dialog";
6
+ import type {
7
+ PermissionPromptUi,
8
+ PromptPreferences,
9
+ requestPermissionDecision,
10
+ } from "#src/authority/permission-prompt-component";
7
11
  import { buildForwardedScopeLabels } from "#src/pattern-suggest";
8
12
  import {
9
13
  emitUiPromptEvent,
@@ -15,12 +19,16 @@ import type { PromptPermissionDetails } from "./permission-prompter";
15
19
 
16
20
  /** Dependencies required by {@link LocalUserAuthorizer}. */
17
21
  export interface LocalUserAuthorizerDeps {
18
- /** The active session's UI surface. */
19
- ui: PermissionDecisionUi;
22
+ /** The active session's UI surface (select/input plus the inline `custom` dialog). */
23
+ ui: PermissionPromptUi;
24
+ /** The session run mode; the dispatcher renders the inline dialog only in `"tui"`. */
25
+ mode: ExtensionContext["mode"];
20
26
  /** Event bus used for the `permissions:ui_prompt` broadcast. */
21
27
  events: PermissionEventBus;
28
+ /** Read live at prompt time so a settings-modal toggle takes effect on the next prompt. */
29
+ getPromptPreferences: () => PromptPreferences;
22
30
  /** Injected for testability; production callers pass the real function. */
23
- requestPermissionDecisionFromUi: typeof requestPermissionDecisionFromUi;
31
+ requestPermissionDecision: typeof requestPermissionDecision;
24
32
  }
25
33
 
26
34
  /**
@@ -41,8 +49,13 @@ export class LocalUserAuthorizer implements Authorizer {
41
49
  ): Promise<PermissionPromptDecision> {
42
50
  const uiPrompt = buildUiPrompt(details);
43
51
  emitUiPromptEvent(this.deps.events, uiPrompt);
44
- return this.deps.requestPermissionDecisionFromUi(
45
- this.deps.ui,
52
+ return this.deps.requestPermissionDecision(
53
+ {
54
+ mode: this.deps.mode,
55
+ ui: this.deps.ui,
56
+ doublePressToConfirm:
57
+ this.deps.getPromptPreferences().doublePressToConfirm,
58
+ },
46
59
  details.forwarding
47
60
  ? "Permission Required (Subagent)"
48
61
  : "Permission Required",
@@ -0,0 +1,300 @@
1
+ import type {
2
+ ExtensionContext,
3
+ ExtensionUIContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ type Component,
7
+ matchesKey,
8
+ truncateToWidth,
9
+ wrapTextWithAnsi,
10
+ } from "@earendil-works/pi-tui";
11
+ import {
12
+ type PermissionPromptDecision,
13
+ type RequestPermissionOptions,
14
+ requestPermissionDecisionFromUi,
15
+ } from "#src/authority/permission-dialog";
16
+ import {
17
+ initialPromptState,
18
+ type PromptEvent,
19
+ type PromptKey,
20
+ type PromptModelConfig,
21
+ type PromptViewState,
22
+ reducePrompt,
23
+ } from "#src/authority/permission-prompt-decision";
24
+
25
+ /**
26
+ * Inline `ctx.ui.custom` permission dialog for TUI sessions.
27
+ *
28
+ * All interaction logic lives in the pure {@link reducePrompt} model; this
29
+ * module is the thin adapter that renders the model's state to lines, maps raw
30
+ * keystrokes to {@link PromptEvent}s, and resolves the `ctx.ui.custom` promise
31
+ * with the committed {@link PermissionPromptDecision}. The component renders
32
+ * inline (never as an overlay).
33
+ */
34
+
35
+ /** The subset of the session UI surface the inline dialog needs. */
36
+ export type PermissionPromptUi = Pick<
37
+ ExtensionUIContext,
38
+ "select" | "input" | "custom"
39
+ >;
40
+
41
+ /** The resolved presentation context selected once per activation. */
42
+ export interface PermissionPromptView {
43
+ mode: ExtensionContext["mode"];
44
+ ui: PermissionPromptUi;
45
+ doublePressToConfirm: boolean;
46
+ }
47
+
48
+ /** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
49
+ export interface PromptPreferences {
50
+ doublePressToConfirm: boolean;
51
+ }
52
+
53
+ /**
54
+ * Route a permission ask to the inline keybind dialog in TUI mode, or the
55
+ * `select()`/`input()` flow otherwise (RPC / frontend — the #519 constraint).
56
+ *
57
+ * The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
58
+ * one place so the fallback and the inline component never both render.
59
+ */
60
+ export function requestPermissionDecision(
61
+ view: PermissionPromptView,
62
+ title: string,
63
+ message: string,
64
+ options?: RequestPermissionOptions,
65
+ ): Promise<PermissionPromptDecision> {
66
+ if (view.mode === "tui") {
67
+ return presentInlinePermissionPrompt(view, title, message, options);
68
+ }
69
+ return requestPermissionDecisionFromUi(view.ui, title, message, options);
70
+ }
71
+
72
+ /** Minimal theme surface the dialog uses; satisfied by the real SDK theme. */
73
+ interface PromptTheme {
74
+ fg(color: string, text: string): string;
75
+ }
76
+
77
+ const DEFAULT_SESSION_LABEL = "Yes, for this session";
78
+
79
+ const OPTION_LABELS: Record<PromptKey, string> = {
80
+ y: "Yes",
81
+ s: DEFAULT_SESSION_LABEL,
82
+ n: "No",
83
+ r: "No, provide reason",
84
+ };
85
+
86
+ const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
87
+
88
+ export function presentInlinePermissionPrompt(
89
+ view: PermissionPromptView,
90
+ title: string,
91
+ message: string,
92
+ options?: RequestPermissionOptions,
93
+ ): Promise<PermissionPromptDecision> {
94
+ const config: PromptModelConfig = {
95
+ doublePressToConfirm: view.doublePressToConfirm,
96
+ sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
97
+ sessionScope: options?.sessionScope,
98
+ };
99
+ return view.ui.custom<PermissionPromptDecision>(
100
+ (tui, theme, _keybindings, done) =>
101
+ new PermissionPromptComponent(
102
+ theme,
103
+ config,
104
+ title,
105
+ message,
106
+ () => {
107
+ tui.requestRender();
108
+ },
109
+ done,
110
+ ),
111
+ { overlay: false },
112
+ );
113
+ }
114
+
115
+ class PermissionPromptComponent implements Component {
116
+ private state: PromptViewState;
117
+ private reasonBuffer = "";
118
+
119
+ constructor(
120
+ private readonly theme: PromptTheme,
121
+ private readonly config: PromptModelConfig,
122
+ private readonly title: string,
123
+ private readonly message: string,
124
+ private readonly requestRender: () => void,
125
+ private readonly done: (decision: PermissionPromptDecision) => void,
126
+ ) {
127
+ this.state = initialPromptState(config);
128
+ }
129
+
130
+ invalidate(): void {
131
+ // No cached rendering state to clear.
132
+ }
133
+
134
+ render(width: number): string[] {
135
+ return fitToWidth(this.renderStep(), width);
136
+ }
137
+
138
+ private renderStep(): string[] {
139
+ switch (this.state.step) {
140
+ case "decision":
141
+ return this.renderDecision();
142
+ case "reason":
143
+ return this.renderReason();
144
+ case "scope":
145
+ return this.renderScope();
146
+ }
147
+ }
148
+
149
+ handleInput(data: string): void {
150
+ if (this.state.step === "reason") {
151
+ this.handleReasonInput(data);
152
+ return;
153
+ }
154
+ const event = this.toEvent(data);
155
+ if (event) {
156
+ this.apply(event);
157
+ }
158
+ }
159
+
160
+ private handleReasonInput(data: string): void {
161
+ if (matchesKey(data, "enter")) {
162
+ this.apply({ type: "submitReason", draft: this.reasonBuffer });
163
+ return;
164
+ }
165
+ if (matchesKey(data, "escape")) {
166
+ this.reasonBuffer = "";
167
+ this.apply({ type: "cancel" });
168
+ return;
169
+ }
170
+ if (matchesKey(data, "backspace")) {
171
+ this.reasonBuffer = this.reasonBuffer.slice(0, -1);
172
+ this.requestRender();
173
+ return;
174
+ }
175
+ if (isPrintable(data)) {
176
+ this.reasonBuffer += data;
177
+ this.requestRender();
178
+ }
179
+ }
180
+
181
+ private toEvent(data: string): PromptEvent | undefined {
182
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
183
+ return { type: "nav", direction: "up" };
184
+ }
185
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
186
+ return { type: "nav", direction: "down" };
187
+ }
188
+ if (matchesKey(data, "enter")) {
189
+ return { type: "confirm" };
190
+ }
191
+ if (matchesKey(data, "escape")) {
192
+ return { type: "cancel" };
193
+ }
194
+ if (this.state.step === "decision") {
195
+ const key = OPTION_ORDER.find((option) => matchesKey(data, option));
196
+ if (key) {
197
+ return { type: "hotkey", key };
198
+ }
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ private apply(event: PromptEvent): void {
204
+ const outcome = reducePrompt(this.config, this.state, event);
205
+ if (outcome.kind === "decision") {
206
+ this.done(outcome.decision);
207
+ return;
208
+ }
209
+ if (outcome.state.step === "reason" && this.state.step !== "reason") {
210
+ this.reasonBuffer = "";
211
+ }
212
+ this.state = outcome.state;
213
+ this.requestRender();
214
+ }
215
+
216
+ private renderDecision(): string[] {
217
+ const lines = [this.theme.fg("accent", this.title), this.message, ""];
218
+ for (const key of OPTION_ORDER) {
219
+ const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
220
+ const selected = this.state.highlightedKey === key;
221
+ const marker = selected ? "▶" : " ";
222
+ const row = `${marker} (${key}) ${label}`;
223
+ lines.push(selected ? this.theme.fg("accent", row) : row);
224
+ }
225
+ lines.push("");
226
+ lines.push(
227
+ this.state.hint ||
228
+ this.theme.fg(
229
+ "muted",
230
+ "↑/↓ move · enter confirm · esc deny · press a letter, then again to confirm",
231
+ ),
232
+ );
233
+ return lines;
234
+ }
235
+
236
+ private renderReason(): string[] {
237
+ const lines = [
238
+ this.theme.fg("accent", this.title),
239
+ this.message,
240
+ "",
241
+ `Reason (required): ${this.reasonBuffer}\u2588`,
242
+ ];
243
+ if (this.state.reasonError) {
244
+ lines.push(this.theme.fg("error", this.state.reasonError));
245
+ }
246
+ lines.push("");
247
+ lines.push(this.theme.fg("muted", "enter submit · esc back"));
248
+ return lines;
249
+ }
250
+
251
+ private renderScope(): string[] {
252
+ const scope = this.config.sessionScope;
253
+ const subagentLabel = scope?.subagentLabel ?? "This subagent only";
254
+ const servingLabel = scope?.servingSessionLabel ?? "The whole session";
255
+ const rows: Array<{ label: string; serving: boolean }> = [
256
+ { label: subagentLabel, serving: false },
257
+ { label: servingLabel, serving: true },
258
+ ];
259
+ const lines = [
260
+ this.theme.fg("accent", this.title),
261
+ "Apply this session grant to:",
262
+ "",
263
+ ];
264
+ for (const row of rows) {
265
+ const selected = this.state.scopeServing === row.serving;
266
+ const marker = selected ? "▶" : " ";
267
+ const text = `${marker} ${row.label}`;
268
+ lines.push(selected ? this.theme.fg("accent", text) : text);
269
+ }
270
+ lines.push("");
271
+ lines.push(this.theme.fg("muted", "↑/↓ move · enter confirm · esc back"));
272
+ return lines;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Fit rendered lines to the terminal width, satisfying the `ctx.ui.custom`
278
+ * contract that every returned line be a single visual row no wider than
279
+ * `width`. Long lines (e.g. a wide tool-preview message) are wrapped rather
280
+ * than clipped so no content is lost; the final `truncateToWidth` guards the
281
+ * edge cases `wrapTextWithAnsi` cannot split (a lone wide grapheme).
282
+ */
283
+ function fitToWidth(lines: string[], width: number): string[] {
284
+ if (width <= 0) {
285
+ return [];
286
+ }
287
+ return lines.flatMap((line) =>
288
+ wrapTextWithAnsi(line, width).map((wrapped) =>
289
+ truncateToWidth(wrapped, width),
290
+ ),
291
+ );
292
+ }
293
+
294
+ function isPrintable(data: string): boolean {
295
+ if (data.length !== 1) {
296
+ return false;
297
+ }
298
+ const code = data.charCodeAt(0);
299
+ return code >= 0x20 && code !== 0x7f;
300
+ }
@@ -0,0 +1,258 @@
1
+ import {
2
+ createDeniedPermissionDecision,
3
+ normalizePermissionDenialReason,
4
+ type PermissionPromptDecision,
5
+ type RequestPermissionOptions,
6
+ } from "#src/authority/permission-dialog";
7
+
8
+ /**
9
+ * Pure decision model for the inline keybind permission dialog.
10
+ *
11
+ * The interaction logic — which hotkey produces which decision, double-press
12
+ * arming, step transitions, and reason validation — lives here with no SDK or
13
+ * TUI imports, so it is unit-testable directly. The `ctx.ui.custom` component
14
+ * ({@link file://./permission-prompt-component.ts}) is a thin adapter that
15
+ * forwards keystrokes to {@link reducePrompt} and renders the returned state.
16
+ */
17
+
18
+ /** The four decision hotkeys, in display order. */
19
+ export type PromptKey = "y" | "s" | "n" | "r";
20
+
21
+ /** Which sub-view the dialog is showing. */
22
+ export type PromptStep = "decision" | "reason" | "scope";
23
+
24
+ const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
25
+
26
+ const OPTION_VERBS: Record<PromptKey, string> = {
27
+ y: "approve",
28
+ s: "approve for this session",
29
+ n: "deny",
30
+ r: "deny with a reason",
31
+ };
32
+
33
+ /** Static configuration for a single prompt presentation. */
34
+ export interface PromptModelConfig {
35
+ /** When true, a letter hotkey arms first and commits only on a second press. */
36
+ doublePressToConfirm: boolean;
37
+ /** Label shown beside the approve-for-session option. */
38
+ sessionLabel: string;
39
+ /**
40
+ * Forwarded asks only: when set, confirming `s` opens a second step choosing
41
+ * whether the grant applies to the requesting subagent only (least-privilege
42
+ * default) or the whole serving session.
43
+ */
44
+ sessionScope?: NonNullable<RequestPermissionOptions["sessionScope"]>;
45
+ }
46
+
47
+ /** The re-render view state the component draws from. */
48
+ export interface PromptViewState {
49
+ step: PromptStep;
50
+ highlightedKey: PromptKey;
51
+ /** Set only while awaiting the confirming second press of a hotkey. */
52
+ armedKey?: PromptKey;
53
+ /** "Press y again to approve." while armed; empty otherwise. */
54
+ hint: string;
55
+ reasonDraft: string;
56
+ /** Set when an empty reason submit is rejected. */
57
+ reasonError?: string;
58
+ /** Scope step: false = subagent-only (default), true = whole serving session. */
59
+ scopeServing: boolean;
60
+ }
61
+
62
+ /** An input event the reducer understands. */
63
+ export type PromptEvent =
64
+ | { type: "nav"; direction: "up" | "down" }
65
+ | { type: "hotkey"; key: PromptKey }
66
+ | { type: "confirm" }
67
+ | { type: "cancel" }
68
+ | { type: "submitReason"; draft: string };
69
+
70
+ /** Either a re-render or a terminal decision. */
71
+ export type PromptOutcome =
72
+ | { kind: "render"; state: PromptViewState }
73
+ | { kind: "decision"; decision: PermissionPromptDecision };
74
+
75
+ export function initialPromptState(
76
+ _config: PromptModelConfig,
77
+ ): PromptViewState {
78
+ return {
79
+ step: "decision",
80
+ highlightedKey: "y",
81
+ armedKey: undefined,
82
+ hint: "",
83
+ reasonDraft: "",
84
+ reasonError: undefined,
85
+ scopeServing: false,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Advance the dialog by one input event, returning either the next view state
91
+ * to render or the committed {@link PermissionPromptDecision}.
92
+ */
93
+ export function reducePrompt(
94
+ config: PromptModelConfig,
95
+ state: PromptViewState,
96
+ event: PromptEvent,
97
+ ): PromptOutcome {
98
+ switch (state.step) {
99
+ case "decision":
100
+ return reduceDecisionStep(config, state, event);
101
+ case "reason":
102
+ return reduceReasonStep(state, event);
103
+ case "scope":
104
+ return reduceScopeStep(state, event);
105
+ }
106
+ }
107
+
108
+ function reduceDecisionStep(
109
+ config: PromptModelConfig,
110
+ state: PromptViewState,
111
+ event: PromptEvent,
112
+ ): PromptOutcome {
113
+ switch (event.type) {
114
+ case "nav":
115
+ return render({
116
+ ...state,
117
+ highlightedKey: shiftKey(state.highlightedKey, event.direction),
118
+ armedKey: undefined,
119
+ hint: "",
120
+ });
121
+ case "hotkey":
122
+ return pressHotkey(config, state, event.key);
123
+ case "confirm":
124
+ return commit(config, state, state.highlightedKey);
125
+ case "cancel":
126
+ return { kind: "decision", decision: createDeniedPermissionDecision() };
127
+ case "submitReason":
128
+ return render(state);
129
+ }
130
+ }
131
+
132
+ function pressHotkey(
133
+ config: PromptModelConfig,
134
+ state: PromptViewState,
135
+ key: PromptKey,
136
+ ): PromptOutcome {
137
+ if (!config.doublePressToConfirm || state.armedKey === key) {
138
+ return commit(config, state, key);
139
+ }
140
+ return render({
141
+ ...state,
142
+ highlightedKey: key,
143
+ armedKey: key,
144
+ hint: `Press ${key} again to ${OPTION_VERBS[key]}.`,
145
+ });
146
+ }
147
+
148
+ function commit(
149
+ config: PromptModelConfig,
150
+ state: PromptViewState,
151
+ key: PromptKey,
152
+ ): PromptOutcome {
153
+ switch (key) {
154
+ case "y":
155
+ return {
156
+ kind: "decision",
157
+ decision: { approved: true, state: "approved" },
158
+ };
159
+ case "n":
160
+ return { kind: "decision", decision: createDeniedPermissionDecision() };
161
+ case "r":
162
+ return render({
163
+ ...state,
164
+ step: "reason",
165
+ highlightedKey: "r",
166
+ armedKey: undefined,
167
+ hint: "",
168
+ reasonDraft: "",
169
+ reasonError: undefined,
170
+ });
171
+ case "s":
172
+ if (config.sessionScope) {
173
+ return render({
174
+ ...state,
175
+ step: "scope",
176
+ highlightedKey: "s",
177
+ armedKey: undefined,
178
+ hint: "",
179
+ scopeServing: false,
180
+ });
181
+ }
182
+ return {
183
+ kind: "decision",
184
+ decision: { approved: true, state: "approved_for_session" },
185
+ };
186
+ }
187
+ }
188
+
189
+ function reduceReasonStep(
190
+ state: PromptViewState,
191
+ event: PromptEvent,
192
+ ): PromptOutcome {
193
+ if (event.type === "cancel") {
194
+ return render({
195
+ ...state,
196
+ step: "decision",
197
+ armedKey: undefined,
198
+ hint: "",
199
+ reasonDraft: "",
200
+ reasonError: undefined,
201
+ });
202
+ }
203
+ if (event.type === "submitReason") {
204
+ const reason = normalizePermissionDenialReason(event.draft);
205
+ if (reason === undefined) {
206
+ return render({
207
+ ...state,
208
+ reasonDraft: event.draft,
209
+ reasonError: "A reason is required.",
210
+ });
211
+ }
212
+ return {
213
+ kind: "decision",
214
+ decision: createDeniedPermissionDecision(reason),
215
+ };
216
+ }
217
+ return render(state);
218
+ }
219
+
220
+ function reduceScopeStep(
221
+ state: PromptViewState,
222
+ event: PromptEvent,
223
+ ): PromptOutcome {
224
+ switch (event.type) {
225
+ case "nav":
226
+ return render({ ...state, scopeServing: event.direction === "down" });
227
+ case "confirm":
228
+ return {
229
+ kind: "decision",
230
+ decision: {
231
+ approved: true,
232
+ state: state.scopeServing
233
+ ? "approved_for_serving_session"
234
+ : "approved_for_session",
235
+ },
236
+ };
237
+ case "cancel":
238
+ return render({
239
+ ...state,
240
+ step: "decision",
241
+ armedKey: undefined,
242
+ hint: "",
243
+ });
244
+ default:
245
+ return render(state);
246
+ }
247
+ }
248
+
249
+ function shiftKey(current: PromptKey, direction: "up" | "down"): PromptKey {
250
+ const index = OPTION_ORDER.indexOf(current);
251
+ const delta = direction === "down" ? 1 : -1;
252
+ const next = (index + delta + OPTION_ORDER.length) % OPTION_ORDER.length;
253
+ return OPTION_ORDER[next] ?? current;
254
+ }
255
+
256
+ function render(state: PromptViewState): PromptOutcome {
257
+ return { kind: "render", state };
258
+ }
@@ -197,6 +197,9 @@ function formatConfigIssues(error: ZodError): string[] {
197
197
  * - Array fields (piInfrastructureReadPaths) replace the base when present in
198
198
  * the override (override-wins, same as scalars).
199
199
  */
200
+ // Scalar knobs merged by override-replaces-base; keep in sync with
201
+ // PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
202
+ // yoloMode, doublePressToConfirm).
200
203
  export function mergeUnifiedConfigs(
201
204
  base: UnifiedPermissionConfig,
202
205
  override: UnifiedPermissionConfig,
@@ -204,7 +207,12 @@ export function mergeUnifiedConfigs(
204
207
  const merged: UnifiedPermissionConfig = {};
205
208
 
206
209
  // Boolean scalars: override replaces base when defined
207
- for (const key of ["debugLog", "permissionReviewLog", "yoloMode"] as const) {
210
+ for (const key of [
211
+ "debugLog",
212
+ "permissionReviewLog",
213
+ "yoloMode",
214
+ "doublePressToConfirm",
215
+ ] as const) {
208
216
  const value = override[key] ?? base[key];
209
217
  if (value !== undefined) {
210
218
  merged[key] = value;
@@ -51,6 +51,7 @@ function cloneDefaultConfig(): PermissionSystemExtensionConfig {
51
51
  debugLog: DEFAULT_EXTENSION_CONFIG.debugLog,
52
52
  permissionReviewLog: DEFAULT_EXTENSION_CONFIG.permissionReviewLog,
53
53
  yoloMode: DEFAULT_EXTENSION_CONFIG.yoloMode,
54
+ doublePressToConfirm: DEFAULT_EXTENSION_CONFIG.doublePressToConfirm,
54
55
  };
55
56
  }
56
57
 
@@ -113,6 +114,14 @@ function buildSettingItems(
113
114
  currentValue: toOnOff(config.debugLog),
114
115
  values: ON_OFF,
115
116
  },
117
+ {
118
+ id: "doublePressToConfirm",
119
+ label: "Double-press to confirm",
120
+ description:
121
+ "Require a confirming second press of a decision hotkey in the inline TUI permission dialog",
122
+ currentValue: toOnOff(config.doublePressToConfirm),
123
+ values: ON_OFF,
124
+ },
116
125
  ];
117
126
  }
118
127
 
@@ -128,6 +137,8 @@ function applySetting(
128
137
  return { ...config, permissionReviewLog: value === "on" };
129
138
  case "debugLog":
130
139
  return { ...config, debugLog: value === "on" };
140
+ case "doublePressToConfirm":
141
+ return { ...config, doublePressToConfirm: value === "on" };
131
142
  default:
132
143
  return config;
133
144
  }
@@ -143,6 +154,10 @@ function syncSettingValues(
143
154
  toOnOff(config.permissionReviewLog),
144
155
  );
145
156
  settingsList.updateValue("debugLog", toOnOff(config.debugLog));
157
+ settingsList.updateValue(
158
+ "doublePressToConfirm",
159
+ toOnOff(config.doublePressToConfirm),
160
+ );
146
161
  }
147
162
 
148
163
  function getArgumentCompletions(
@@ -180,6 +180,13 @@ export const unifiedConfigSchema = z
180
180
  "Auto-approve `ask`-state permission checks, including subagent approval forwarding.\n\n⚠️ **Use with caution** — this disables all interactive confirmation prompts.",
181
181
  default: false,
182
182
  }),
183
+ doublePressToConfirm: z.boolean().optional().meta({
184
+ description:
185
+ "Require a confirming second press of a decision hotkey in the inline permission dialog. Applies to TUI sessions only.",
186
+ markdownDescription:
187
+ "Require a confirming second press of a decision hotkey (`y`/`s`/`n`/`r`) in the inline permission dialog before it commits — the first press arms the action and shows a `Press y again to approve.` hint.\n\nApplies to interactive **TUI** sessions only; the non-TUI (RPC/frontend) prompt keeps its single-select flow. Set to `false` to commit decisions on the first hotkey press.",
188
+ default: true,
189
+ }),
183
190
  toolInputPreviewMaxLength: z.number().int().min(1).optional().meta({
184
191
  description:
185
192
  "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
@@ -13,6 +13,8 @@ export interface PermissionSystemExtensionConfig {
13
13
  debugLog: boolean;
14
14
  permissionReviewLog: boolean;
15
15
  yoloMode: boolean;
16
+ /** Require a confirming second press of a decision hotkey in the inline TUI dialog. Defaults to true. */
17
+ doublePressToConfirm: boolean;
16
18
  /** Additional directories to auto-allow for reads as Pi infrastructure. */
17
19
  piInfrastructureReadPaths?: string[];
18
20
  /** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
@@ -27,6 +29,7 @@ export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
27
29
  debugLog: false,
28
30
  permissionReviewLog: true,
29
31
  yoloMode: false,
32
+ doublePressToConfirm: true,
30
33
  };
31
34
 
32
35
  function resolveExtensionRoot(moduleUrl = import.meta.url): string {
@@ -58,6 +61,7 @@ export function normalizePermissionSystemConfig(
58
61
  debugLog: raw.debugLog === true,
59
62
  permissionReviewLog: raw.permissionReviewLog !== false,
60
63
  yoloMode: raw.yoloMode === true,
64
+ doublePressToConfirm: raw.doublePressToConfirm !== false,
61
65
  };
62
66
  if (raw.piInfrastructureReadPaths !== undefined) {
63
67
  result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  type ServingPolicy,
9
9
  } from "./authority/forwarded-request-server";
10
10
  import { ForwardingManager } from "./authority/forwarding-manager";
11
- import { requestPermissionDecisionFromUi } from "./authority/permission-dialog";
11
+ import { requestPermissionDecision } from "./authority/permission-prompt-component";
12
12
  import { PermissionPrompter } from "./authority/permission-prompter";
13
13
  import { SubagentDetection } from "./authority/subagent-detection";
14
14
  import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
@@ -100,7 +100,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
100
100
  const authorizerSelection = new AuthorizerSelection({
101
101
  detection: subagentDetection,
102
102
  events: pi.events,
103
- requestPermissionDecisionFromUi,
103
+ getPromptPreferences: () => ({
104
+ doublePressToConfirm: configStore.current().doublePressToConfirm,
105
+ }),
106
+ requestPermissionDecision,
104
107
  forwardingDir: paths.forwardingDir,
105
108
  registry: subagentRegistry,
106
109
  logger,