@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 +12 -0
- package/README.md +1 -0
- package/config/config.example.json +7 -0
- package/docs/configuration.md +41 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +37 -0
- package/src/authority/permission-prompt-component.ts +41 -21
- package/src/authority/permission-prompt-decision.ts +76 -59
- package/src/config/config-loader.ts +39 -0
- package/src/config/config-schema.ts +47 -0
- package/src/config/dialog-keys.ts +172 -0
- package/src/config/extension-config.ts +6 -0
- package/src/index.ts +2 -0
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
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<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
332
|
-
matchesKey(data, option),
|
|
339
|
+
const action = visibleActions(this.config).find((option) =>
|
|
340
|
+
matchesKey(data, this.boundKey(option)),
|
|
333
341
|
);
|
|
334
|
-
if (
|
|
335
|
-
return { type: "hotkey",
|
|
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
|
|
358
|
-
const label = this.labelFor(
|
|
359
|
-
const selected = this.state.
|
|
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} (${
|
|
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(
|
|
374
|
-
if (
|
|
375
|
-
if (
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
(
|
|
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
|
|
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
|
|
53
|
+
export function visibleActions(
|
|
49
54
|
config: PromptModelConfig,
|
|
50
|
-
): readonly
|
|
55
|
+
): readonly PromptAction[] {
|
|
51
56
|
return config.widthLabel ? OPTION_ORDER : NARROW_OPTION_ORDER;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
const OPTION_VERBS: Record<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
98
|
+
highlightedAction: PromptAction;
|
|
87
99
|
/** Set only while awaiting the confirming second press of a hotkey. */
|
|
88
|
-
|
|
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";
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
171
|
-
? pressHotkey(config, state, event.
|
|
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.
|
|
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
|
-
|
|
201
|
+
action: PromptAction,
|
|
186
202
|
): PromptOutcome {
|
|
187
|
-
if (!config.doublePressToConfirm || state.
|
|
188
|
-
return commit(config, state,
|
|
203
|
+
if (!config.doublePressToConfirm || state.armedAction === action) {
|
|
204
|
+
return commit(config, state, action);
|
|
189
205
|
}
|
|
190
206
|
return render({
|
|
191
207
|
...state,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
hint: `Press ${
|
|
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
|
-
|
|
217
|
+
action: PromptAction,
|
|
202
218
|
): PromptOutcome {
|
|
203
|
-
switch (
|
|
204
|
-
case "
|
|
219
|
+
switch (action) {
|
|
220
|
+
case "approve":
|
|
205
221
|
return {
|
|
206
222
|
kind: "decision",
|
|
207
223
|
decision: { approved: true, state: "approved" },
|
|
208
224
|
};
|
|
209
|
-
case "
|
|
225
|
+
case "deny":
|
|
210
226
|
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
|
211
|
-
case "
|
|
227
|
+
case "denyWithReason":
|
|
212
228
|
return render({
|
|
213
229
|
...state,
|
|
214
230
|
step: "reason",
|
|
215
|
-
|
|
216
|
-
|
|
231
|
+
highlightedAction: "denyWithReason",
|
|
232
|
+
armedAction: undefined,
|
|
217
233
|
hint: "",
|
|
218
234
|
reasonError: undefined,
|
|
219
235
|
});
|
|
220
|
-
case "
|
|
221
|
-
case "
|
|
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 =
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
340
|
+
function shiftAction(
|
|
324
341
|
config: PromptModelConfig,
|
|
325
|
-
current:
|
|
342
|
+
current: PromptAction,
|
|
326
343
|
direction: "up" | "down",
|
|
327
|
-
):
|
|
328
|
-
const
|
|
329
|
-
const index =
|
|
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 +
|
|
332
|
-
return
|
|
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,
|