@gotgenes/pi-permission-system 32.0.6 → 32.1.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
@@ -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
+ ## [32.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.6...pi-permission-system-v32.1.0) (2026-09-16)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** let the permission dialog's hotkeys be remapped ([e8fa79b](https://github.com/gotgenes/pi-packages/commit/e8fa79b6a30b925441aa2afb21c13fb10ff0b133)), closes [#927](https://github.com/gotgenes/pi-packages/issues/927)
14
+ * **pi-permission-system:** report a refused permission-dialog key binding ([6798c63](https://github.com/gotgenes/pi-packages/commit/6798c6359cee2abed5f3e1cf28f10002d22d765d)), closes [#927](https://github.com/gotgenes/pi-packages/issues/927)
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** document permissionDialogKeys ([63d3221](https://github.com/gotgenes/pi-packages/commit/63d3221c5b3eac76843c63b4d1e81e8afde60c8f)), closes [#927](https://github.com/gotgenes/pi-packages/issues/927)
19
+
8
20
  ## [32.0.6](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.5...pi-permission-system-v32.0.6) (2026-09-16)
9
21
 
10
22
 
package/README.md CHANGED
@@ -66,6 +66,7 @@ All permissions use one of three states:
66
66
 
67
67
  When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
68
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
+ The hotkeys themselves are remappable through `permissionDialogKeys`, which matters if you type with an input method editor: composition mode swallows letter keys before they reach the terminal, and digits do not.
69
70
  A file-access ask that proves a single direction offers `b` as well, granting the session both directions instead of only the one the gate proved.
70
71
  The prompt shows one fact per line — who is asking, the tool, the matched rule, the value being decided — within a row budget, so a large tool input cannot take over the transcript; `Ctrl+O` (`app.tools.expand`) expands it to the complete request.
71
72
  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.
@@ -5,6 +5,13 @@
5
5
  "permissionReviewLog": true,
6
6
  "yoloMode": false,
7
7
  "doublePressToConfirm": true,
8
+ "permissionDialogKeys": {
9
+ "approve": "y",
10
+ "approveSession": "s",
11
+ "approveSessionBoth": "b",
12
+ "deny": "n",
13
+ "denyWithReason": "r"
14
+ },
8
15
 
9
16
  "forwardingTimeoutMs": 600000,
10
17
 
@@ -37,6 +37,7 @@ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating
37
37
 
38
38
  The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
39
39
  Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
40
+ `permissionDialogKeys` replaces the whole map rather than merging entry by entry, so the map that was validated is the map that applies.
40
41
 
41
42
  **Invalid higher-precedence scope fails closed.**
42
43
  If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
@@ -103,6 +104,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
103
104
  | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings, masked only where a name binds the secret — see [Log file sensitivity](#log-file-sensitivity) |
104
105
  | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
105
106
  | `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. |
107
+ | `permissionDialogKeys` | — | Remaps the inline TUI dialog's decision hotkeys (see below). One printable character per decision; omitted decisions keep `y` / `s` / `b` / `n` / `r`. |
106
108
  | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
107
109
  | `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
108
110
  | `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
@@ -132,12 +134,50 @@ Every other ask shows the four options above without it.
132
134
  See [session-approvals.md](session-approvals.md#grant-direction) for what the two widths grant.
133
135
 
134
136
  Arrow keys / `j`/`k` move the highlight, `enter` confirms the highlighted option, and `esc` denies.
135
- 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.
137
+ With `doublePressToConfirm` enabled (the default), a hotkey **arms** its action and shows a `Press y again to approve.` hint; press the same key again to commit.
136
138
  Set `doublePressToConfirm` to `false` to commit on the first press.
137
139
 
140
+ #### Remapping the hotkeys
141
+
142
+ Set `permissionDialogKeys` to bind any decision to a different key:
143
+
144
+ ```jsonc
145
+ {
146
+ "permissionDialogKeys": {
147
+ "approve": "1",
148
+ "approveSession": "2",
149
+ "approveSessionBoth": "3",
150
+ "deny": "4",
151
+ "denyWithReason": "5"
152
+ }
153
+ }
154
+ ```
155
+
156
+ The five decision names above are the only keys the map accepts, and each is optional — a decision you do not name keeps its default letter.
157
+
158
+ This exists for input method editors.
159
+ While an IME is composing — Chinese Pinyin or Wubi, Japanese, Korean — a letter keypress is consumed by the candidate buffer and never reaches the terminal, so `y` and `n` do nothing and the dialog looks frozen.
160
+ The usual way out of a candidate popup is `esc`, which *does* reach the terminal and which this dialog reads as a denial, so a call you meant to approve gets refused.
161
+ Digits are unaffected on essentially every layout, which is why `1`–`5` is the mapping to reach for.
162
+
163
+ Each value is a **single printable character**: a lowercase letter, a digit, or a symbol.
164
+ Three things are refused:
165
+
166
+ - `j` and `k`, which move the dialog's highlight — a decision bound to one would never fire.
167
+ - An uppercase letter.
168
+ Pi lowercases a key identifier, so `"Y"` would answer to a lowercase `y` rather than to the keystroke you asked for.
169
+ - A character two decisions would share, including one a decision you did **not** remap already holds.
170
+ Trading two decisions' keys is fine (`{"approve": "n", "deny": "y"}`), because neither keeps the other's.
171
+
172
+ A refused entry is a warning, never a policy event: that decision keeps its default letter, the rest of the map still applies, and your permission rules are untouched.
173
+ Named keys (`f1`, `pageUp`) and modifier combinations (`ctrl+g`) are not accepted.
174
+
175
+ One collision no check can see: if you rebind Pi's own `app.tools.expand` to a printable character that is also a dialog binding, expansion wins — the dialog offers that action first, before it maps a decision key.
176
+
138
177
  Pi's tool-expansion binding (`app.tools.expand`, `Ctrl+O` by default) stays live while the dialog is open.
139
178
  It expands both the prompt itself — to the complete request, unbounded by `promptMaxRows` and `promptFieldMaxWidth` — and the host's pending tool call, so one keystroke shows you everything before you decide.
140
179
  It only toggles the display — it never resolves, commits, or arms the pending decision.
180
+ Because it is offered ahead of the decision keys, a printable rebinding of it shadows a `permissionDialogKeys` entry that names the same character.
141
181
  While you are typing a denial reason it is not intercepted, so a rebound printable key still reaches the reason editor.
142
182
 
143
183
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "32.0.6",
3
+ "version": "32.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,6 +31,43 @@
31
31
  "default": true,
32
32
  "type": "boolean"
33
33
  },
34
+ "permissionDialogKeys": {
35
+ "type": "object",
36
+ "properties": {
37
+ "approve": {
38
+ "description": "Key that approves the pending call once. Default: y.",
39
+ "type": "string"
40
+ },
41
+ "approveSession": {
42
+ "description": "Key that approves for the rest of the session. Default: s.",
43
+ "type": "string"
44
+ },
45
+ "approveSessionBoth": {
46
+ "description": "Key that approves for the session in both directions. Default: b.",
47
+ "type": "string"
48
+ },
49
+ "deny": {
50
+ "description": "Key that denies the pending call. Default: n.",
51
+ "type": "string"
52
+ },
53
+ "denyWithReason": {
54
+ "description": "Key that denies and opens the reason editor. Default: r.",
55
+ "type": "string"
56
+ }
57
+ },
58
+ "additionalProperties": false,
59
+ "description": "Remaps the inline TUI permission dialog's decision hotkeys. Each value is one printable character.",
60
+ "markdownDescription": "Remaps the inline **TUI** permission dialog's decision hotkeys, which default to `y` / `s` / `b` / `n` / `r`.\n\nEach value is a single printable character — a lowercase letter, a digit, or a symbol. Digits are the usual choice for input-method-editor (IME) users, whose composition mode swallows letter keys before they reach the terminal.\n\n`j` and `k` are reserved for moving the dialog's highlight, uppercase is rejected (pi lowercases a key identifier, so `\"Y\"` would answer to `y`), and two decisions may not share a character. An entry that breaks one of those rules is ignored with a warning and its decision keeps its default letter.\n\nA project config replaces a global one's map entirely rather than merging entry by entry.",
61
+ "examples": [
62
+ {
63
+ "approve": "1",
64
+ "approveSession": "2",
65
+ "approveSessionBoth": "3",
66
+ "deny": "4",
67
+ "denyWithReason": "5"
68
+ }
69
+ ]
70
+ },
34
71
  "forwardingTimeoutMs": {
35
72
  "description": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
36
73
  "markdownDescription": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
@@ -3,7 +3,13 @@ import type {
3
3
  ExtensionUIContext,
4
4
  KeybindingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { type Component, Input, matchesKey } from "@earendil-works/pi-tui";
6
+ import {
7
+ type Component,
8
+ Input,
9
+ type KeyId,
10
+ matchesKey,
11
+ } from "@earendil-works/pi-tui";
12
+ import type { DialogKeyBindings, PromptAction } from "#src/config/dialog-keys";
7
13
  import {
8
14
  completeViewBudget,
9
15
  type DialogView,
@@ -23,11 +29,10 @@ import {
23
29
  import {
24
30
  initialPromptState,
25
31
  type PromptEvent,
26
- type PromptKey,
27
32
  type PromptModelConfig,
28
33
  type PromptViewState,
29
34
  reducePrompt,
30
- visibleOptionKeys,
35
+ visibleActions,
31
36
  } from "./permission-prompt-decision";
32
37
 
33
38
  /**
@@ -60,6 +65,8 @@ export interface PromptPreferences {
60
65
  doublePressToConfirm: boolean;
61
66
  /** How much room a render has; the terminal width is added per frame. */
62
67
  budget: RenderBudget;
68
+ /** The character bound to each decision. */
69
+ dialogKeys: DialogKeyBindings;
63
70
  }
64
71
 
65
72
  /**
@@ -122,12 +129,12 @@ interface PromptTheme {
122
129
 
123
130
  const DEFAULT_SESSION_LABEL = "Yes, for this session";
124
131
 
125
- const OPTION_LABELS: Record<PromptKey, string> = {
126
- y: "Yes",
127
- s: DEFAULT_SESSION_LABEL,
128
- b: "Yes, for this session in both directions",
129
- n: "No",
130
- r: "No, provide reason",
132
+ const OPTION_LABELS: Record<PromptAction, string> = {
133
+ approve: "Yes",
134
+ approveSession: DEFAULT_SESSION_LABEL,
135
+ approveSessionBoth: "Yes, for this session in both directions",
136
+ deny: "No",
137
+ denyWithReason: "No, provide reason",
131
138
  };
132
139
 
133
140
  export function presentInlinePermissionPrompt(
@@ -141,6 +148,7 @@ export function presentInlinePermissionPrompt(
141
148
  sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
142
149
  widthLabel: options?.sessionWidth?.label,
143
150
  sessionScope: options?.sessionScope,
151
+ keys: view.dialogKeys,
144
152
  };
145
153
  return view.ui.custom<UnattributedDecision>(
146
154
  (tui, theme, keybindings, done) =>
@@ -328,11 +336,11 @@ class PermissionPromptComponent implements Component {
328
336
  return { type: "cancel" };
329
337
  }
330
338
  if (this.state.step === "decision") {
331
- const key = visibleOptionKeys(this.config).find((option) =>
332
- matchesKey(data, option),
339
+ const action = visibleActions(this.config).find((option) =>
340
+ matchesKey(data, this.boundKey(option)),
333
341
  );
334
- if (key) {
335
- return { type: "hotkey", key };
342
+ if (action) {
343
+ return { type: "hotkey", action };
336
344
  }
337
345
  }
338
346
  return undefined;
@@ -354,11 +362,11 @@ class PermissionPromptComponent implements Component {
354
362
  private renderDecision(width: number): string[] {
355
363
  const ask = this.renderAsk(width);
356
364
  const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
357
- for (const key of visibleOptionKeys(this.config)) {
358
- const label = this.labelFor(key);
359
- const selected = this.state.highlightedKey === key;
365
+ for (const action of visibleActions(this.config)) {
366
+ const label = this.labelFor(action);
367
+ const selected = this.state.highlightedAction === action;
360
368
  const marker = selected ? "▶" : " ";
361
- const row = `${marker} (${key}) ${label}`;
369
+ const row = `${marker} (${this.boundKey(action)}) ${label}`;
362
370
  lines.push(selected ? this.theme.fg("accent", row) : row);
363
371
  }
364
372
  lines.push("");
@@ -366,14 +374,26 @@ class PermissionPromptComponent implements Component {
366
374
  return lines;
367
375
  }
368
376
 
377
+ /**
378
+ * The character that selects an option, as a key identifier.
379
+ *
380
+ * The cast is total by construction: a binding is one printable character,
381
+ * which is exactly what pi-tui's matcher accepts as a `KeyId`.
382
+ */
383
+ private boundKey(action: PromptAction): KeyId {
384
+ return this.config.keys[action] as KeyId;
385
+ }
386
+
369
387
  /**
370
388
  * The row label for a key: the two session options carry ask-supplied text
371
389
  * naming what they grant, and the rest are fixed.
372
390
  */
373
- private labelFor(key: PromptKey): string {
374
- if (key === "s") return this.config.sessionLabel;
375
- if (key === "b") return this.config.widthLabel ?? OPTION_LABELS.b;
376
- return OPTION_LABELS[key];
391
+ private labelFor(action: PromptAction): string {
392
+ if (action === "approveSession") return this.config.sessionLabel;
393
+ if (action === "approveSessionBoth") {
394
+ return this.config.widthLabel ?? OPTION_LABELS.approveSessionBoth;
395
+ }
396
+ return OPTION_LABELS[action];
377
397
  }
378
398
 
379
399
  private renderReason(width: number): string[] {
@@ -1,3 +1,4 @@
1
+ import type { DialogKeyBindings, PromptAction } from "#src/config/dialog-keys";
1
2
  import type { SessionGrantWidth } from "#src/session/approval-grant";
2
3
  import {
3
4
  createDeniedPermissionDecision,
@@ -16,26 +17,30 @@ import {
16
17
  * forwards keystrokes to {@link reducePrompt} and renders the returned state.
17
18
  */
18
19
 
19
- /**
20
- * The decision hotkeys, in display order.
21
- *
22
- * `b` is conditional: it appears only for an ask whose session grant can be
23
- * widened to both directions (#813), so the roster an ask actually offers
24
- * comes from {@link visibleOptionKeys} rather than from this type.
25
- */
26
- export type PromptKey = "y" | "s" | "b" | "n" | "r";
27
-
28
20
  /** Which sub-view the dialog is showing. */
29
21
  export type PromptStep = "decision" | "reason" | "scope";
30
22
 
31
- const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "b", "n", "r"];
23
+ /**
24
+ * The decisions in display order.
25
+ *
26
+ * `approveSessionBoth` is conditional: it appears only for an ask whose session
27
+ * grant can be widened to both directions (#813), so the roster an ask actually
28
+ * offers comes from {@link visibleActions} rather than from this list.
29
+ */
30
+ const OPTION_ORDER: readonly PromptAction[] = [
31
+ "approve",
32
+ "approveSession",
33
+ "approveSessionBoth",
34
+ "deny",
35
+ "denyWithReason",
36
+ ];
32
37
 
33
- const NARROW_OPTION_ORDER: readonly PromptKey[] = OPTION_ORDER.filter(
34
- (key) => key !== "b",
38
+ const NARROW_OPTION_ORDER: readonly PromptAction[] = OPTION_ORDER.filter(
39
+ (action) => action !== "approveSessionBoth",
35
40
  );
36
41
 
37
42
  /**
38
- * The decision step's option keys, in display order.
43
+ * The decision step's options, in display order.
39
44
  *
40
45
  * A function of the config rather than an exported constant, so which options
41
46
  * an ask offers is decided in the model and the component renders whatever it
@@ -45,24 +50,31 @@ const NARROW_OPTION_ORDER: readonly PromptKey[] = OPTION_ORDER.filter(
45
50
  * The width option is offered iff the ask supplied a label for it, so an ask
46
51
  * that proves no single direction is rendered and navigated exactly as before.
47
52
  */
48
- export function visibleOptionKeys(
53
+ export function visibleActions(
49
54
  config: PromptModelConfig,
50
- ): readonly PromptKey[] {
55
+ ): readonly PromptAction[] {
51
56
  return config.widthLabel ? OPTION_ORDER : NARROW_OPTION_ORDER;
52
57
  }
53
58
 
54
- const OPTION_VERBS: Record<PromptKey, string> = {
55
- y: "approve",
56
- s: "approve for this session",
57
- b: "approve both directions for this session",
58
- n: "deny",
59
- r: "deny with a reason",
59
+ const OPTION_VERBS: Record<PromptAction, string> = {
60
+ approve: "approve",
61
+ approveSession: "approve for this session",
62
+ approveSessionBoth: "approve both directions for this session",
63
+ deny: "deny",
64
+ denyWithReason: "deny with a reason",
60
65
  };
61
66
 
62
67
  /** Static configuration for a single prompt presentation. */
63
68
  export interface PromptModelConfig {
64
- /** When true, a letter hotkey arms first and commits only on a second press. */
69
+ /** When true, a hotkey arms first and commits only on a second press. */
65
70
  doublePressToConfirm: boolean;
71
+ /**
72
+ * The character bound to each decision.
73
+ *
74
+ * What the dialog matches keystrokes against and what it renders, so an
75
+ * option's identity and the key that selects it are separate values.
76
+ */
77
+ keys: DialogKeyBindings;
66
78
  /** Label shown beside the approve-for-session option. */
67
79
  sessionLabel: string;
68
80
  /**
@@ -83,9 +95,9 @@ export interface PromptModelConfig {
83
95
  /** The re-render view state the component draws from. */
84
96
  export interface PromptViewState {
85
97
  step: PromptStep;
86
- highlightedKey: PromptKey;
98
+ highlightedAction: PromptAction;
87
99
  /** Set only while awaiting the confirming second press of a hotkey. */
88
- armedKey?: PromptKey;
100
+ armedAction?: PromptAction;
89
101
  /** "Press y again to approve." while armed; empty otherwise. */
90
102
  hint: string;
91
103
  /** Set when an empty reason submit is rejected. */
@@ -106,7 +118,7 @@ export interface PromptViewState {
106
118
  /** An input event the reducer understands. */
107
119
  export type PromptEvent =
108
120
  | { type: "nav"; direction: "up" | "down" }
109
- | { type: "hotkey"; key: PromptKey }
121
+ | { type: "hotkey"; action: PromptAction }
110
122
  | { type: "confirm" }
111
123
  | { type: "cancel" }
112
124
  | { type: "submitReason"; draft: string };
@@ -121,8 +133,8 @@ export function initialPromptState(
121
133
  ): PromptViewState {
122
134
  return {
123
135
  step: "decision",
124
- highlightedKey: "y",
125
- armedKey: undefined,
136
+ highlightedAction: "approve",
137
+ armedAction: undefined,
126
138
  hint: "",
127
139
  reasonError: undefined,
128
140
  scopeServing: false,
@@ -162,16 +174,20 @@ function reduceDecisionStep(
162
174
  case "nav":
163
175
  return render({
164
176
  ...state,
165
- highlightedKey: shiftKey(config, state.highlightedKey, event.direction),
166
- armedKey: undefined,
177
+ highlightedAction: shiftAction(
178
+ config,
179
+ state.highlightedAction,
180
+ event.direction,
181
+ ),
182
+ armedAction: undefined,
167
183
  hint: "",
168
184
  });
169
185
  case "hotkey":
170
- return visibleOptionKeys(config).includes(event.key)
171
- ? pressHotkey(config, state, event.key)
186
+ return visibleActions(config).includes(event.action)
187
+ ? pressHotkey(config, state, event.action)
172
188
  : render(state);
173
189
  case "confirm":
174
- return commit(config, state, state.highlightedKey);
190
+ return commit(config, state, state.highlightedAction);
175
191
  case "cancel":
176
192
  return { kind: "decision", decision: createDeniedPermissionDecision() };
177
193
  case "submitReason":
@@ -182,52 +198,53 @@ function reduceDecisionStep(
182
198
  function pressHotkey(
183
199
  config: PromptModelConfig,
184
200
  state: PromptViewState,
185
- key: PromptKey,
201
+ action: PromptAction,
186
202
  ): PromptOutcome {
187
- if (!config.doublePressToConfirm || state.armedKey === key) {
188
- return commit(config, state, key);
203
+ if (!config.doublePressToConfirm || state.armedAction === action) {
204
+ return commit(config, state, action);
189
205
  }
190
206
  return render({
191
207
  ...state,
192
- highlightedKey: key,
193
- armedKey: key,
194
- hint: `Press ${key} again to ${OPTION_VERBS[key]}.`,
208
+ highlightedAction: action,
209
+ armedAction: action,
210
+ hint: `Press ${config.keys[action]} again to ${OPTION_VERBS[action]}.`,
195
211
  });
196
212
  }
197
213
 
198
214
  function commit(
199
215
  config: PromptModelConfig,
200
216
  state: PromptViewState,
201
- key: PromptKey,
217
+ action: PromptAction,
202
218
  ): PromptOutcome {
203
- switch (key) {
204
- case "y":
219
+ switch (action) {
220
+ case "approve":
205
221
  return {
206
222
  kind: "decision",
207
223
  decision: { approved: true, state: "approved" },
208
224
  };
209
- case "n":
225
+ case "deny":
210
226
  return { kind: "decision", decision: createDeniedPermissionDecision() };
211
- case "r":
227
+ case "denyWithReason":
212
228
  return render({
213
229
  ...state,
214
230
  step: "reason",
215
- highlightedKey: "r",
216
- armedKey: undefined,
231
+ highlightedAction: "denyWithReason",
232
+ armedAction: undefined,
217
233
  hint: "",
218
234
  reasonError: undefined,
219
235
  });
220
- case "s":
221
- case "b": {
236
+ case "approveSession":
237
+ case "approveSessionBoth": {
222
238
  // The two session options differ only in the width they grant; which
223
239
  // scope they land on is the forwarded scope step's separate question.
224
- const grantWidth: SessionGrantWidth = key === "b" ? "family" : "proven";
240
+ const grantWidth: SessionGrantWidth =
241
+ action === "approveSessionBoth" ? "family" : "proven";
225
242
  if (config.sessionScope) {
226
243
  return render({
227
244
  ...state,
228
245
  step: "scope",
229
- highlightedKey: key,
230
- armedKey: undefined,
246
+ highlightedAction: action,
247
+ armedAction: undefined,
231
248
  hint: "",
232
249
  scopeServing: false,
233
250
  grantWidth,
@@ -268,7 +285,7 @@ function reduceReasonStep(
268
285
  return render({
269
286
  ...state,
270
287
  step: "decision",
271
- armedKey: undefined,
288
+ armedAction: undefined,
272
289
  hint: "",
273
290
  reasonError: undefined,
274
291
  grantWidth: "proven",
@@ -311,7 +328,7 @@ function reduceScopeStep(
311
328
  return render({
312
329
  ...state,
313
330
  step: "decision",
314
- armedKey: undefined,
331
+ armedAction: undefined,
315
332
  hint: "",
316
333
  grantWidth: "proven",
317
334
  });
@@ -320,16 +337,16 @@ function reduceScopeStep(
320
337
  }
321
338
  }
322
339
 
323
- function shiftKey(
340
+ function shiftAction(
324
341
  config: PromptModelConfig,
325
- current: PromptKey,
342
+ current: PromptAction,
326
343
  direction: "up" | "down",
327
- ): PromptKey {
328
- const keys = visibleOptionKeys(config);
329
- const index = keys.indexOf(current);
344
+ ): PromptAction {
345
+ const actions = visibleActions(config);
346
+ const index = actions.indexOf(current);
330
347
  const delta = direction === "down" ? 1 : -1;
331
- const next = (index + delta + keys.length) % keys.length;
332
- return keys[next] ?? current;
348
+ const next = (index + delta + actions.length) % actions.length;
349
+ return actions[next] ?? current;
333
350
  }
334
351
 
335
352
  function render(state: PromptViewState): PromptOutcome {
@@ -16,6 +16,7 @@ import {
16
16
  type UnifiedPermissionConfig,
17
17
  unifiedConfigSchema,
18
18
  } from "./config-schema";
19
+ import { type DialogKeysConfig, resolveDialogKeys } from "./dialog-keys";
19
20
 
20
21
  // The unified config shape is derived from the zod schema (config-schema.ts,
21
22
  // the single source of truth) and re-exported so existing importers keep their
@@ -196,6 +197,7 @@ function formatConfigIssues(error: ZodError): string[] {
196
197
  * present in the override.
197
198
  * - Array fields (piInfrastructureReadPaths) replace the base when present in
198
199
  * the override (override-wins, same as scalars).
200
+ * - `permissionDialogKeys` replaces the base map whole, unlike `shellTools`.
199
201
  */
200
202
  // Scalar knobs merged by override-replaces-base; keep in sync with
201
203
  // PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
@@ -242,6 +244,16 @@ export function mergeUnifiedConfigs(
242
244
  }
243
245
  }
244
246
 
247
+ // permissionDialogKeys: whole-object replacement. A key map is validated as
248
+ // a unit, so merging two individually valid maps could bind one character to
249
+ // two decisions with neither file's own validation able to see it. Dropping a
250
+ // base entry only restores a default letter, which is why this does not need
251
+ // the shellTools rule below.
252
+ const dialogKeys = override.permissionDialogKeys ?? base.permissionDialogKeys;
253
+ if (dialogKeys !== undefined) {
254
+ merged.permissionDialogKeys = dialogKeys;
255
+ }
256
+
245
257
  // shellTools: shallow-merge by tool name so a project entry overrides a
246
258
  // colliding tool's alias but never drops a global entry (a dropped alias is
247
259
  // a silent enforcement regression).
@@ -378,6 +390,9 @@ export function loadAndMergeConfigs(
378
390
  const deprecatedCapsIssue = detectDeprecatedPreviewCaps(merged);
379
391
  if (deprecatedCapsIssue) allIssues.push(deprecatedCapsIssue);
380
392
 
393
+ const dialogKeysIssue = detectUnusableDialogKeys(merged);
394
+ if (dialogKeysIssue) allIssues.push(dialogKeysIssue);
395
+
381
396
  return {
382
397
  global: globalConfig,
383
398
  project: projectConfig,
@@ -445,6 +460,30 @@ export function detectDeprecatedPreviewCaps(
445
460
  );
446
461
  }
447
462
 
463
+ /**
464
+ * Detect a `permissionDialogKeys` entry the dialog cannot honor.
465
+ *
466
+ * Deliberately a warning rather than a fail-closed rejection: a mistyped hotkey
467
+ * is cosmetic, and clamping the session's `allow` rules to `ask` over one would
468
+ * make a display preference a policy event. The decision keeps its default
469
+ * letter, and the message names the entry, the reason, and the letter kept.
470
+ *
471
+ * Where that message surfaces is the caller's problem and is currently a narrow
472
+ * one: `ConfigStore` dedupes against a warning recorded by a factory-time
473
+ * refresh with no ctx to notify, so an issue already on disk reaches the debug
474
+ * log alone. That predates this detector and swallows its two siblings the same
475
+ * way (#933).
476
+ *
477
+ * Pure, following {@link detectPermissiveBashFallback}: it takes the merged
478
+ * config and returns a message; the caller owns pushing it onto the issue list.
479
+ */
480
+ export function detectUnusableDialogKeys(
481
+ config: DialogKeysConfig,
482
+ ): string | undefined {
483
+ const { issues } = resolveDialogKeys(config);
484
+ return issues.length === 0 ? undefined : issues.join(" ");
485
+ }
486
+
448
487
  /**
449
488
  * Load and normalize a unified config file.
450
489
  * Returns an empty config with no issues if the file does not exist.
@@ -245,6 +245,52 @@ function rejectUnusableSurfaceKeys(
245
245
  }
246
246
  }
247
247
 
248
+ /**
249
+ * The inline dialog's hotkey bindings, one printable character per decision.
250
+ *
251
+ * The schema checks *shape* only — an unknown decision name or a non-string
252
+ * value is a load-time error like any other malformed field, because a
253
+ * misspelled key would otherwise sit inert with no feedback. Whether a
254
+ * well-formed string is a *usable* binding is `resolveDialogKeys`' question,
255
+ * and it answers tolerantly: an unusable, reserved, or colliding entry keeps
256
+ * its default letter and is reported, so a mistyped hotkey never reaches the
257
+ * permission policy.
258
+ */
259
+ const dialogKeysSchema = z
260
+ .strictObject({
261
+ approve: z.string().optional().meta({
262
+ description: "Key that approves the pending call once. Default: y.",
263
+ }),
264
+ approveSession: z.string().optional().meta({
265
+ description: "Key that approves for the rest of the session. Default: s.",
266
+ }),
267
+ approveSessionBoth: z.string().optional().meta({
268
+ description:
269
+ "Key that approves for the session in both directions. Default: b.",
270
+ }),
271
+ deny: z.string().optional().meta({
272
+ description: "Key that denies the pending call. Default: n.",
273
+ }),
274
+ denyWithReason: z.string().optional().meta({
275
+ description: "Key that denies and opens the reason editor. Default: r.",
276
+ }),
277
+ })
278
+ .meta({
279
+ description:
280
+ "Remaps the inline TUI permission dialog's decision hotkeys. Each value is one printable character.",
281
+ markdownDescription:
282
+ "Remaps the inline **TUI** permission dialog's decision hotkeys, which default to `y` / `s` / `b` / `n` / `r`.\n\nEach value is a single printable character — a lowercase letter, a digit, or a symbol. Digits are the usual choice for input-method-editor (IME) users, whose composition mode swallows letter keys before they reach the terminal.\n\n`j` and `k` are reserved for moving the dialog's highlight, uppercase is rejected (pi lowercases a key identifier, so `\"Y\"` would answer to `y`), and two decisions may not share a character. An entry that breaks one of those rules is ignored with a warning and its decision keeps its default letter.\n\nA project config replaces a global one's map entirely rather than merging entry by entry.",
283
+ examples: [
284
+ {
285
+ approve: "1",
286
+ approveSession: "2",
287
+ approveSessionBoth: "3",
288
+ deny: "4",
289
+ denyWithReason: "5",
290
+ },
291
+ ],
292
+ });
293
+
248
294
  const shellToolAliasSchema = z
249
295
  .strictObject({
250
296
  commandArgument: z.string().min(1).meta({
@@ -322,6 +368,7 @@ export const unifiedConfigSchema = z
322
368
  "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.",
323
369
  default: true,
324
370
  }),
371
+ permissionDialogKeys: dialogKeysSchema.optional(),
325
372
  forwardingTimeoutMs: z.number().int().min(1).optional().meta({
326
373
  description:
327
374
  "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The inline permission dialog's key bindings, and the defaults they start from.
3
+ *
4
+ * The action ids are the `permissionDialogKeys` config keys, so the config
5
+ * surface and the decision model speak one vocabulary rather than two that a
6
+ * translation layer has to keep in step.
7
+ */
8
+
9
+ /**
10
+ * The dialog's five decisions, named by what they do.
11
+ *
12
+ * Distinct from the character that selects one: a binding is configurable and
13
+ * an identity is not, so a rebound dialog still decides the same five things.
14
+ */
15
+ export type PromptAction =
16
+ | "approve"
17
+ | "approveSession"
18
+ | "approveSessionBoth"
19
+ | "deny"
20
+ | "denyWithReason";
21
+
22
+ /** Every action's bound character, complete. */
23
+ export type DialogKeyBindings = Readonly<Record<PromptAction, string>>;
24
+
25
+ /** What a config file may say: any subset of the actions. */
26
+ export type DialogKeyOverrides = Partial<Record<PromptAction, string>>;
27
+
28
+ /** The config slice the resolver reads. */
29
+ export interface DialogKeysConfig {
30
+ readonly permissionDialogKeys?: DialogKeyOverrides;
31
+ }
32
+
33
+ /** The bindings to use, and what had to be refused to arrive at them. */
34
+ export interface DialogKeyResolution {
35
+ readonly keys: DialogKeyBindings;
36
+ /** One sentence per refused override; empty when every one applied. */
37
+ readonly issues: readonly string[];
38
+ }
39
+
40
+ /** The decisions in the order a config's overrides are considered. */
41
+ const ACTION_ORDER: readonly PromptAction[] = [
42
+ "approve",
43
+ "approveSession",
44
+ "approveSessionBoth",
45
+ "deny",
46
+ "denyWithReason",
47
+ ];
48
+
49
+ /** The shipped bindings, unchanged since the dialog was introduced. */
50
+ export const DEFAULT_DIALOG_KEYS: DialogKeyBindings = {
51
+ approve: "y",
52
+ approveSession: "s",
53
+ approveSessionBoth: "b",
54
+ deny: "n",
55
+ denyWithReason: "r",
56
+ };
57
+
58
+ const LOWERCASE_LETTERS = "abcdefghijklmnopqrstuvwxyz";
59
+ const DIGITS = "0123456789";
60
+
61
+ /**
62
+ * pi-tui's symbol keys, minus `+`.
63
+ *
64
+ * `+` separates a modifier from its key in a key identifier, so pi-tui's
65
+ * parser splits `"+"` into two empty halves and the binding matches nothing.
66
+ * A parity test in `test/config/dialog-keys.test.ts` derives this roster from
67
+ * pi-tui's exported `Key` constant and fails if the two drift; keeping the
68
+ * literal here is what lets this module stay free of SDK imports.
69
+ */
70
+ const SYMBOLS = "`-=[]\\;',./!@#$%^&*()_|~{}:<>?";
71
+
72
+ /**
73
+ * Every character a decision may be bound to.
74
+ *
75
+ * Uppercase is excluded deliberately rather than normalized: pi-tui lowercases
76
+ * a key identifier, so a `"Y"` binding would answer to a lowercase `y` and
77
+ * never to the keystroke the user asked for.
78
+ */
79
+ export const BINDABLE_DIALOG_KEY_CHARACTERS: ReadonlySet<string> = new Set(
80
+ Array.from(LOWERCASE_LETTERS + DIGITS + SYMBOLS),
81
+ );
82
+
83
+ /** Whether `value` is a single character the dialog can bind a decision to. */
84
+ export function isBindableDialogKey(value: string): boolean {
85
+ return BINDABLE_DIALOG_KEY_CHARACTERS.has(value);
86
+ }
87
+
88
+ /**
89
+ * The characters the dialog answers to before any decision does.
90
+ *
91
+ * `handleInput` maps these to navigation ahead of the decision table, so a
92
+ * decision bound to one would simply never fire.
93
+ */
94
+ export const RESERVED_DIALOG_KEYS: readonly string[] = ["j", "k"];
95
+
96
+ /**
97
+ * The bindings a config asks for, reconciled against the defaults.
98
+ *
99
+ * Tolerant by design: an unusable, reserved, or colliding override is refused
100
+ * and its decision keeps the shipped letter, so a mistyped hotkey costs the
101
+ * user their remap and nothing else. The refusals come back as sentences for
102
+ * the caller to surface; the permission policy never sees them.
103
+ */
104
+ export function resolveDialogKeys(
105
+ config: DialogKeysConfig,
106
+ ): DialogKeyResolution {
107
+ const overrides = config.permissionDialogKeys;
108
+ const issues: string[] = [];
109
+ const accepted = new Map<PromptAction, string>();
110
+
111
+ for (const action of ACTION_ORDER) {
112
+ const value = overrides?.[action];
113
+ if (value === undefined) continue;
114
+ if (!isBindableDialogKey(value)) {
115
+ issues.push(
116
+ refusal(
117
+ action,
118
+ value,
119
+ "is not a bindable key. Use one lowercase letter, digit, or symbol",
120
+ ),
121
+ );
122
+ continue;
123
+ }
124
+ if (RESERVED_DIALOG_KEYS.includes(value)) {
125
+ issues.push(
126
+ refusal(action, value, "is reserved for moving the dialog's highlight"),
127
+ );
128
+ continue;
129
+ }
130
+ accepted.set(action, value);
131
+ }
132
+
133
+ // Dropping an override restores its default, and that default can collide
134
+ // with an override that survived the previous look — so this settles rather
135
+ // than checks once. Each round drops at least one override and there are at
136
+ // most five, and the all-defaults state it terminates at is collision-free.
137
+ for (let round = ACTION_ORDER.length; round > 0; round--) {
138
+ const contested = contestedCharacters(accepted);
139
+ if (contested.size === 0) break;
140
+ for (const [action, value] of accepted) {
141
+ if (!contested.has(value)) continue;
142
+ accepted.delete(action);
143
+ issues.push(
144
+ refusal(action, value, "is already bound to another decision"),
145
+ );
146
+ }
147
+ }
148
+
149
+ const keys: Record<PromptAction, string> = { ...DEFAULT_DIALOG_KEYS };
150
+ for (const [action, value] of accepted) {
151
+ keys[action] = value;
152
+ }
153
+ return { keys, issues };
154
+ }
155
+
156
+ /** The characters two decisions would answer to, given the accepted overrides. */
157
+ function contestedCharacters(
158
+ accepted: ReadonlyMap<PromptAction, string>,
159
+ ): ReadonlySet<string> {
160
+ const seen = new Set<string>();
161
+ const contested = new Set<string>();
162
+ for (const action of ACTION_ORDER) {
163
+ const value = accepted.get(action) ?? DEFAULT_DIALOG_KEYS[action];
164
+ if (seen.has(value)) contested.add(value);
165
+ seen.add(value);
166
+ }
167
+ return contested;
168
+ }
169
+
170
+ function refusal(action: PromptAction, value: string, why: string): string {
171
+ return `permissionDialogKeys.${action}: "${value}" ${why}; keeping the default "${DEFAULT_DIALOG_KEYS[action]}".`;
172
+ }
@@ -9,6 +9,7 @@ import type {
9
9
  ShellToolsConfig,
10
10
  UnifiedPermissionConfig,
11
11
  } from "./config-loader";
12
+ import type { DialogKeyOverrides } from "./dialog-keys";
12
13
 
13
14
  export const EXTENSION_ID = "pi-permission-system";
14
15
 
@@ -28,6 +29,8 @@ export interface PermissionSystemExtensionConfig {
28
29
  promptFieldMaxWidth?: number;
29
30
  /** Max characters of any one value written to the permission review log. Defaults to 1000. */
30
31
  reviewLogFieldMaxWidth?: number;
32
+ /** The inline dialog's hotkey bindings, as configured; resolved at prompt time. */
33
+ permissionDialogKeys?: DialogKeyOverrides;
31
34
  /** Non-bash tools that carry shell semantics, keyed by tool name. */
32
35
  shellTools?: ShellToolsConfig;
33
36
  /** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
@@ -91,6 +94,9 @@ export function normalizePermissionSystemConfig(
91
94
  // absent: the schema and the merge still accept them so the deprecation
92
95
  // detector can see an operator's setting, but no runtime consumer may read
93
96
  // one (ADR 0011 §5, #745).
97
+ if (raw.permissionDialogKeys !== undefined) {
98
+ result.permissionDialogKeys = raw.permissionDialogKeys;
99
+ }
94
100
  if (raw.shellTools !== undefined) {
95
101
  result.shellTools = raw.shellTools;
96
102
  }
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ import { getSubagentSessionRegistry } from "#src/authority/subagent-registry";
36
36
  import { registerPermissionSystemCommand } from "#src/config/config-modal";
37
37
  import { getGlobalConfigPath } from "#src/config/config-paths";
38
38
  import { ConfigStore } from "#src/config/config-store";
39
+ import { resolveDialogKeys } from "#src/config/dialog-keys";
39
40
  import { isYoloModeEnabled } from "#src/config/extension-config";
40
41
  import { computeExtensionPaths } from "#src/config/extension-paths";
41
42
  import { GateRunner } from "#src/handlers/gates/runner";
@@ -150,6 +151,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
150
151
  getPromptPreferences: () => ({
151
152
  doublePressToConfirm: configStore.current().doublePressToConfirm,
152
153
  budget: resolveRenderBudget(configStore.current()),
154
+ dialogKeys: resolveDialogKeys(configStore.current()).keys,
153
155
  }),
154
156
  requestPermissionDecision,
155
157
  forwardingDir: paths.forwardingDir,