@adeildo/pi-ask-permission 5.0.0 → 5.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/CONTRIBUTING.md +1 -1
- package/README.md +82 -275
- package/assets/preview.png +0 -0
- package/package.json +3 -2
- package/src/index.ts +4 -1
- package/src/pi/events.ts +2 -24
- package/src/ui/decision-options.ts +0 -2
- package/src/ui/questions.ts +27 -3
- package/src/ui/clipboard.ts +0 -51
- package/src/ui/dialog.ts +0 -507
- package/src/ui/paste.ts +0 -27
package/CONTRIBUTING.md
CHANGED
|
@@ -19,7 +19,7 @@ src/
|
|
|
19
19
|
core/ # policy and judging, no pi and no TUI
|
|
20
20
|
config/ # schema, decode, store, patterns
|
|
21
21
|
judge/ # pipeline, compose, request, policy, backends
|
|
22
|
-
ui/ #
|
|
22
|
+
ui/ # the permission question for the questions dialog, the host selector, the judge entry
|
|
23
23
|
util/ # decoders and primitives
|
|
24
24
|
```
|
|
25
25
|
|
package/README.md
CHANGED
|
@@ -3,19 +3,16 @@
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<a href="https://github.com/felipeadeildo/pi-harness/actions/workflows/ci.yml"><img src="https://github.com/felipeadeildo/pi-harness/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
5
5
|
<a href="https://www.npmjs.com/package/@adeildo/pi-ask-permission"><img src="https://img.shields.io/npm/v/@adeildo/pi-ask-permission" alt="npm"></a>
|
|
6
|
-
<a href="https://www.npmjs.com/package/@adeildo/pi-ask-permission"><img src="https://img.shields.io/npm/dm/@adeildo/pi-ask-permission" alt="downloads"></a>
|
|
7
6
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="license"></a>
|
|
8
|
-
<a href="https://www.npmjs.com/package/@adeildo/pi-ask-permission"><img src="https://img.shields.io/badge/provenance-signed-success" alt="provenance"></a>
|
|
9
|
-
<a href="https://pi.dev/packages/@adeildo/pi-ask-permission"><img src="https://img.shields.io/badge/pi--package-6E56CF" alt="pi package"></a>
|
|
10
7
|
<a href="https://pi.dev"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffelipeadeildo%2Fpi-harness%2Fmain%2Fpackage.json&query=%24.devDependencies%5B%22%40earendil-works%2Fpi-coding-agent%22%5D&label=pi%20SDK&color=6E56CF" alt="pi SDK"></a>
|
|
11
8
|
</p>
|
|
12
9
|
|
|
13
10
|
Pi runs every tool call without asking. This extension asks first.
|
|
14
11
|
|
|
15
|
-
Answer `yes`, `always yes
|
|
12
|
+
Answer `yes`, `always yes` or `no`, and leave a note if you want. The note reaches the model with the result, so denying `npm install` with "use pnpm instead" corrects the agent without stopping it. A judge model can answer the routine calls for you, so you only see the ones it doubts.
|
|
16
13
|
|
|
17
14
|
<p align="center">
|
|
18
|
-
<img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/ask-permission/assets/preview.png" alt="The permission dialog for npm install, with the
|
|
15
|
+
<img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/ask-permission/assets/preview.png" alt="The permission dialog for npm install, with the reason it asks, the three answers, and a note on no: use pnpm instead." width="860">
|
|
19
16
|
</p>
|
|
20
17
|
|
|
21
18
|
## Install
|
|
@@ -24,82 +21,63 @@ Answer `yes`, `always yes`, or `deny`, and add a note if you want. The note reac
|
|
|
24
21
|
pi install npm:@adeildo/pi-ask-permission
|
|
25
22
|
```
|
|
26
23
|
|
|
27
|
-
|
|
24
|
+
There is nothing to configure, and the dialog comes with it. It also comes in [`@adeildo/pi-harness`](../harness), with the rest of the pieces.
|
|
28
25
|
|
|
29
|
-
`pi-ask-permission` on npm is this same extension under an older name, and it reads the same
|
|
26
|
+
`pi-ask-permission` on npm is this same extension under an older name, and it reads the same settings, grants and sessions. To switch, run `pi remove npm:pi-ask-permission` and install this one.
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
pi remove npm:pi-ask-permission
|
|
33
|
-
pi install npm:@adeildo/pi-ask-permission
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## First run
|
|
28
|
+
## The dialog
|
|
37
29
|
|
|
38
30
|
Ask the agent to do something that writes, like `run the tests`. The dialog opens before the command runs:
|
|
39
31
|
|
|
40
|
-
|
|
41
|
-
╭─ permission bash ────────────────────────────────────╮
|
|
42
|
-
│ pnpm test │
|
|
43
|
-
│ │
|
|
44
|
-
│ ❯ 1 yes │
|
|
45
|
-
│ 2 always yes │
|
|
46
|
-
│ 3 deny │
|
|
47
|
-
│ │
|
|
48
|
-
│ ↑↓ or 1-3 pick enter confirm tab note esc deny │
|
|
49
|
-
╰──────────────────────────────────────────────────────╯
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
`enter` approves. `esc` denies. `tab` opens a note on the highlighted row.
|
|
53
|
-
|
|
54
|
-
Reads inside the project never ask. `read`, `grep`, `find`, `ls`, and bash commands that only read, like `cat`, `git log`, or `rg`, run on their own. An `edit` or `write` shows the diff it would make.
|
|
32
|
+
<!-- docs:ask-permission/ask -->
|
|
55
33
|
|
|
56
|
-
|
|
34
|
+
```text
|
|
35
|
+
╭─ permission ─────────────────────────────────────────────────────────────────────────────────────╮
|
|
36
|
+
│ │
|
|
37
|
+
│ ▎ bash wants to run: │
|
|
38
|
+
│ ▎ npm install │
|
|
39
|
+
│ ▎ │
|
|
40
|
+
│ ▎ ▲ the judge leaned deny but was only 61% confident │
|
|
41
|
+
│ │
|
|
42
|
+
│ 1 yes, run it │ Block it. A note on this answer becomes the reason the │
|
|
43
|
+
│ 2 always yes │ model reads. │
|
|
44
|
+
│ ❯ 3 no › │ │
|
|
45
|
+
│ │ › use pnpm instead │
|
|
46
|
+
│ │
|
|
47
|
+
│ ↑↓ move enter choose tab note esc cancel │
|
|
48
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
49
|
+
```
|
|
57
50
|
|
|
58
|
-
|
|
51
|
+
<!-- /docs -->
|
|
59
52
|
|
|
60
|
-
|
|
61
|
-
╭─ permission ─────────────────────────────────────────╮
|
|
62
|
-
│ bash wants to run: │
|
|
63
|
-
│ │
|
|
64
|
-
│ git push --force origin main │
|
|
65
|
-
│ │
|
|
66
|
-
│ ▲ the judge leaned deny but was only 61% confident │
|
|
67
|
-
│ │
|
|
68
|
-
│ ❯ 1 yes, run it │
|
|
69
|
-
│ Run this call now, and ask again next time. │
|
|
70
|
-
│ 2 always yes │
|
|
71
|
-
│ 3 no │
|
|
72
|
-
│ 4 Type something. │
|
|
73
|
-
│ │
|
|
74
|
-
│ ↑↓ or 1-4 move enter choose tab note esc cancel │
|
|
75
|
-
╰──────────────────────────────────────────────────────╯
|
|
76
|
-
```
|
|
53
|
+
`enter` picks the highlighted answer and `esc` cancels, which denies. `tab` writes a note on the highlighted answer, and the note on `no` is the reason the model reads. A command longer than three lines shows its first three and how many are left.
|
|
77
54
|
|
|
78
|
-
|
|
55
|
+
Reads inside the project never ask: `read`, `grep`, `find`, `ls`, and bash commands that only read, like `cat`, `git log` or `rg`. An `edit` or `write` shows the diff it would make.
|
|
79
56
|
|
|
80
|
-
|
|
57
|
+
This is the dialog of [`@adeildo/pi-ask-questions`](../ask-questions), which comes with this package, so the permission ask and the model's questions share the same keys and notes. If you switch that feature off in `pi config`, and in RPC hosts that cannot draw a terminal component, Pi asks through its own selector, with the same answers and no panel.
|
|
81
58
|
|
|
82
|
-
##
|
|
59
|
+
## Stop answering the same question
|
|
83
60
|
|
|
84
|
-
|
|
61
|
+
Pick `always yes` and it asks what to remember, then for how long:
|
|
85
62
|
|
|
86
|
-
|
|
63
|
+
<!-- docs:ask-permission/remember -->
|
|
87
64
|
|
|
65
|
+
```text
|
|
66
|
+
╭─ remember ───────────────────────────────────────────────────────────────────────────────────────╮
|
|
67
|
+
│ │
|
|
68
|
+
│ ▎ Always say yes to which calls, from bash? │
|
|
69
|
+
│ │
|
|
70
|
+
│ ❯ 1 pnpm test │ Only this exact call. │
|
|
71
|
+
│ 2 pnpm │ │
|
|
72
|
+
│ │ │
|
|
73
|
+
│ │
|
|
74
|
+
│ ↑↓ move enter choose tab note esc cancel │
|
|
75
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
88
76
|
```
|
|
89
|
-
╭─ permission bash ────────────────────────────────────╮
|
|
90
|
-
│ pnpm test │
|
|
91
|
-
│ │
|
|
92
|
-
│ always yes for... │
|
|
93
|
-
│ pnpm │
|
|
94
|
-
│ ❯ pnpm test │
|
|
95
|
-
│ │
|
|
96
|
-
│ scope: this session (tab to change) │
|
|
97
|
-
│ │
|
|
98
|
-
│ ↑↓ depth tab scope enter confirm esc back │
|
|
99
|
-
╰──────────────────────────────────────────────────────╯
|
|
100
|
-
```
|
|
101
77
|
|
|
102
|
-
|
|
78
|
+
<!-- /docs -->
|
|
79
|
+
|
|
80
|
+
The first option is the narrowest, and that is the one to prefer: `pnpm` would also approve `pnpm publish`.
|
|
103
81
|
|
|
104
82
|
| Scope | Lasts | Stored in |
|
|
105
83
|
| ------------ | ---------------------------------------- | ---------------------------------------------------------- |
|
|
@@ -107,11 +85,11 @@ We recommend the narrowest level, which is preselected. `pnpm` would also approv
|
|
|
107
85
|
| this project | every session in this project | `.pi/extensions/pi-ask-permission/always-yes.json` |
|
|
108
86
|
| everywhere | every session | `~/.pi/agent/extensions/pi-ask-permission/always-yes.json` |
|
|
109
87
|
|
|
110
|
-
The `Always yes` section of the settings
|
|
88
|
+
The `Always yes` section of the settings (`Alt+S`) shows how many rules each scope holds, and forgets them.
|
|
111
89
|
|
|
112
|
-
|
|
90
|
+
## Let the agent work
|
|
113
91
|
|
|
114
|
-
|
|
92
|
+
`Alt+M` switches modes, and the status bar shows the one you are in.
|
|
115
93
|
|
|
116
94
|
| Mode | Runs without asking |
|
|
117
95
|
| -------- | -------------------------------------------------------------------------- |
|
|
@@ -120,37 +98,40 @@ Press `Alt+M` to switch modes. The status bar shows the one you are in.
|
|
|
120
98
|
| `judge` | the same as `edits`, and the [judge](#let-a-model-decide) decides the rest |
|
|
121
99
|
| `full` | everything |
|
|
122
100
|
|
|
123
|
-
The workspace comes before the mode. A call outside `workspace.roots` asks you in every mode, even `full`. `Alt+W` lets calls outside through for this session, and a second press puts the check back. The status bar shows `anywhere` in red while it is off.
|
|
101
|
+
The workspace comes before the mode. A call outside `workspace.roots` asks you in every mode, even `full`. `Alt+W` lets calls outside through for this session, and a second press puts the check back. The status bar shows `anywhere` in red while it is off. A resumed session keeps its mode and its `Alt+W` choice.
|
|
124
102
|
|
|
125
|
-
|
|
103
|
+
### A folder next door
|
|
126
104
|
|
|
127
|
-
|
|
105
|
+
In a monorepo, a session in `apps/api` that reads `apps/web` leaves the workspace. The dialog says so and offers to open the repository for reads:
|
|
128
106
|
|
|
129
|
-
|
|
107
|
+
<!-- docs:ask-permission/folder -->
|
|
130
108
|
|
|
131
109
|
```text
|
|
132
|
-
╭─ permission
|
|
133
|
-
│
|
|
134
|
-
│
|
|
135
|
-
│
|
|
136
|
-
│
|
|
137
|
-
│
|
|
138
|
-
│
|
|
139
|
-
│
|
|
140
|
-
│
|
|
141
|
-
│
|
|
142
|
-
|
|
110
|
+
╭─ permission ─────────────────────────────────────────────────────────────────────────────────────╮
|
|
111
|
+
│ │
|
|
112
|
+
│ ▎ bash wants to run: │
|
|
113
|
+
│ ▎ cd ~/Projects/grace/apps/web && git status --short | head │
|
|
114
|
+
│ ▎ │
|
|
115
|
+
│ ▎ ▲ reads outside the workspace │
|
|
116
|
+
│ │
|
|
117
|
+
│ 1 yes, run it │ yes, and allow reads in /home/me/Projects/grace │
|
|
118
|
+
│ 2 always yes │ │
|
|
119
|
+
│ ❯ 3 yes, and allow reads in /home/me/Proj… │ Run it, and let later calls read there without │
|
|
120
|
+
│ 4 no │ asking. │
|
|
121
|
+
│ │ │
|
|
122
|
+
│ │ │
|
|
123
|
+
│ │
|
|
124
|
+
│ ↑↓ move enter choose tab note esc cancel │
|
|
125
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
143
126
|
```
|
|
144
127
|
|
|
145
|
-
|
|
128
|
+
<!-- /docs -->
|
|
146
129
|
|
|
147
|
-
A write outside gets `yes, and add … to the workspace` instead
|
|
148
|
-
|
|
149
|
-
The status bar counts the open folders, like `+1 folder`. The `Folders` section of the settings screen closes them.
|
|
130
|
+
Take it and the next reads there run without asking, for as long as you choose. A write outside gets `yes, and add … to the workspace` instead. A folder opened for reads never lets a write through, and the dialog never offers your home or a folder above it. The status bar counts the open folders, like `+1 folder`, and the `Folders` section of the settings closes them.
|
|
150
131
|
|
|
151
132
|
### Calls to an MCP server
|
|
152
133
|
|
|
153
|
-
Pi registers each tool an MCP server offers as `mcp__<server>__<tool>`. The settings
|
|
134
|
+
Pi registers each tool an MCP server offers as `mcp__<server>__<tool>`. The settings have a row per server, and each follows a policy:
|
|
154
135
|
|
|
155
136
|
| Policy | Runs without asking |
|
|
156
137
|
| ------------- | -------------------------------------------------------------------------- |
|
|
@@ -159,53 +140,18 @@ Pi registers each tool an MCP server offers as `mcp__<server>__<tool>`. The sett
|
|
|
159
140
|
| `allow` | Every call |
|
|
160
141
|
| `deny` | Nothing. Every call is blocked, whatever the mode says |
|
|
161
142
|
|
|
162
|
-
`trust hints` is the default, and the hint is what the server claims about its own tool. Pi does not verify it, so a server you do not fully trust belongs on `ask me`.
|
|
163
|
-
|
|
164
|
-
`deny` and `ask me` outrank the mode, `full` included. The `allow` list does not bring a denied server back.
|
|
165
|
-
|
|
166
|
-
The resource tools pi adds for reading resources name the server in their arguments rather than in the tool name, so no server policy covers them: `list_mcp_resources`, `list_mcp_resource_templates` and `read_mcp_resource`. They declare themselves read-only, so the read-only layer runs them whatever the mode.
|
|
167
|
-
|
|
168
|
-
The dialog names the server and repeats what it declares, and says when a script issued the call:
|
|
169
|
-
|
|
170
|
-
```text
|
|
171
|
-
╭─ permission sauron:delete_dashboard ────────────────────────╮
|
|
172
|
-
│ {"uid":"abc","id":12} │
|
|
173
|
-
│ sauron: destructive │
|
|
174
|
-
│ │
|
|
175
|
-
│ 1 yes │
|
|
176
|
-
│ 2 always yes │
|
|
177
|
-
│ ❯ 3 deny │
|
|
178
|
-
╰─────────────────────────────────────────────────────────────╯
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
Pi's `codemode` tool runs a script that calls other tools, and `tool_search` loads a tool for the next call. Both run without asking, because every call they make reaches this gate on its own, and the dialog says it came from a script.
|
|
182
|
-
|
|
183
|
-
### Correct the agent
|
|
184
|
-
|
|
185
|
-
A note on `deny` tells the agent what to do instead. A note on `yes` adds context, like `and update the snapshot`. Both reach the model with the tool result.
|
|
186
|
-
|
|
187
|
-
If you are typing in the editor when a call arrives, the dialog waits until you pause.
|
|
143
|
+
`trust hints` is the default, and the hint is what the server claims about its own tool. Pi does not verify it, so a server you do not fully trust belongs on `ask me`. `deny` and `ask me` outrank the mode, `full` included. The dialog names the server, repeats what it declares, and says when a script issued the call.
|
|
188
144
|
|
|
189
145
|
## Let a model decide
|
|
190
146
|
|
|
191
|
-
In the `judge` mode
|
|
147
|
+
In the `judge` mode a model answers every call that is not a read or an edit, and you only see the ones it is unsure about. A good way in:
|
|
192
148
|
|
|
193
149
|
1. Run `/login typesafe` to use Jev, a fast model that answers with a confidence. Any model you set up in pi works too.
|
|
194
150
|
2. Switch to `judge` with `Alt+M`, open the settings with `Alt+S`, and turn on `Dry run` in `Judge`. The judge now shows its verdict as a card, and you still decide.
|
|
195
|
-
3. Pick a policy. `Standard development` allows edits, tests, builds
|
|
151
|
+
3. Pick a policy. `Standard development` allows edits, tests, builds and local git, and asks about installs, network and anything destructive.
|
|
196
152
|
4. After a few sessions of agreeing with it, turn off `Dry run`.
|
|
197
153
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
| Row | Does |
|
|
201
|
-
| ------------- | ------------------------------------------------------------------------------------------- |
|
|
202
|
-
| Model | Who judges. A Jev name goes to TypeSafe, and `provider/model` is any model you set up in pi |
|
|
203
|
-
| Policy | The rules the judge follows, in plain English |
|
|
204
|
-
| Always ask me | Patterns the judge never approves, like `git push*` |
|
|
205
|
-
| Rigor | How sure the judge must be before a call runs without you |
|
|
206
|
-
| Dry run | Show the verdict, and still ask you |
|
|
207
|
-
|
|
208
|
-
A call runs when the judge approves it with at least the rigor's confidence, and its risk stays at or below the ceiling. Anything else asks you.
|
|
154
|
+
A call runs when the judge approves it with at least the confidence of the rigor you picked, and its risk stays under the ceiling. Anything else asks you.
|
|
209
155
|
|
|
210
156
|
| Rigor | Confidence | Risk ceiling |
|
|
211
157
|
| ---------------------- | ---------- | ------------ |
|
|
@@ -213,9 +159,7 @@ A call runs when the judge approves it with at least the rigor's confidence, and
|
|
|
213
159
|
| `balanced`, by default | 70% | 0.50 |
|
|
214
160
|
| `relaxed` | 55% | 0.60 |
|
|
215
161
|
|
|
216
|
-
The
|
|
217
|
-
|
|
218
|
-
The policy is plain text, so you can start from a preset and edit it:
|
|
162
|
+
The policy is plain text, so start from a preset and edit it:
|
|
219
163
|
|
|
220
164
|
```text
|
|
221
165
|
# May run without asking
|
|
@@ -230,155 +174,18 @@ The policy is plain text, so you can start from a preset and edit it:
|
|
|
230
174
|
Ask me.
|
|
231
175
|
```
|
|
232
176
|
|
|
233
|
-
If calls come back as `the judge could not decide`, run `Test the judge` in the `Judge` section
|
|
234
|
-
|
|
235
|
-
## Settings
|
|
236
|
-
|
|
237
|
-
`Alt+S` opens them. The Permission tab:
|
|
238
|
-
|
|
239
|
-
| Section | Has |
|
|
240
|
-
| ------------ | ------------------------------------------------- |
|
|
241
|
-
| This session | Mode and outside policy for this session |
|
|
242
|
-
| New sessions | The mode and outside policy a session starts with |
|
|
243
|
-
| Workspace | The project folders |
|
|
244
|
-
| MCP servers | One row per server, with its policy |
|
|
245
|
-
| Reads | Tools that never ask, read-only bash |
|
|
246
|
-
| Dialog | Notes, no-dialog behavior, typing pause |
|
|
247
|
-
| Judge | Model, policy, rigor, dry run, a test, the log |
|
|
248
|
-
| Always yes | Rule counts, and forget |
|
|
249
|
-
| Folders | Folders opened from the dialog, and close |
|
|
250
|
-
|
|
251
|
-
Type to search. `Delete` resets a value.
|
|
252
|
-
|
|
253
|
-
## Reference
|
|
254
|
-
|
|
255
|
-
### Dialog keys
|
|
177
|
+
`Always ask me` lists patterns the judge never approves, like `git push*`. The judge also reads your last message to tell whether a call is a step of what you asked, but nothing in the message overrides the policy or `Always ask me`. If calls come back as `the judge could not decide`, run `Test the judge` in the `Judge` section: it sends one request and reports the model, the latency and the error.
|
|
256
178
|
|
|
257
|
-
|
|
258
|
-
| ---------------------- | ---------------------------------------------------- |
|
|
259
|
-
| `↑` `↓` or `1` `2` `3` | Move the highlight |
|
|
260
|
-
| `enter` | Confirm the highlighted row |
|
|
261
|
-
| `←` `→` | Pick the folder to open, on the folder row |
|
|
262
|
-
| `s` | Keep the folder for this project, on the folder row |
|
|
263
|
-
| `tab` | Open or close a note, or change the always yes scope |
|
|
264
|
-
| `esc` | Close the note, or deny |
|
|
265
|
-
| `ctrl+v` | Paste a clipboard image as its file path |
|
|
266
|
-
|
|
267
|
-
A long paste collapses to `[paste #1 +48 lines]` and expands when you confirm.
|
|
268
|
-
|
|
269
|
-
### Configuration
|
|
270
|
-
|
|
271
|
-
The settings live in the file every piece shares, `~/.pi/agent/extensions/pi-harness/settings.json`, under a `permission.` prefix. Most keys have a row on the settings screen, which shows the key under its description. `mcp.servers` gets one row per connected server, and the finer judge keys below live in the file only. Only what you change is written, so a new default reaches you.
|
|
272
|
-
|
|
273
|
-
```json
|
|
274
|
-
{
|
|
275
|
-
"permission": {
|
|
276
|
-
"allow": ["read", "grep", "find", "ls"],
|
|
277
|
-
"mode": "manual",
|
|
278
|
-
"readOnlyBash": true,
|
|
279
|
-
"workspace": { "roots": ["."], "outside": "ask" },
|
|
280
|
-
"judge": { "model": "jev-latest" }
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
The ids below leave out the `permission.` prefix.
|
|
286
|
-
|
|
287
|
-
| Key | Does |
|
|
288
|
-
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
289
|
-
| `allow` | Tools that never ask. `mcp__*` matches a family. It matches the tool name, so `bash` allows every command. An MCP server policy comes first |
|
|
290
|
-
| `mode` | The mode a new session starts in: `"manual"`, `"edits"`, `"judge"`, or `"full"` |
|
|
291
|
-
| `readOnlyBash` | Run bash commands that only read without asking |
|
|
292
|
-
| `notes` | `"result"` adds a note to the tool result. `"message"` sends it as its own message |
|
|
293
|
-
| `noUI` | `"allow"` or `"deny"` when nobody can answer, as in print mode or a subagent. Takes a per-tool map: `{ "*": "allow", "bash": "deny" }` |
|
|
294
|
-
| `workspace.roots` | Paths that count as the project. Relative, absolute, and `~` work |
|
|
295
|
-
| `workspace.outside` | Where a new session starts for a call outside the roots: `"ask"` you, `"deny"` it, or `"allow"` it like any other |
|
|
296
|
-
| `typing.pause` | Milliseconds of quiet before the dialog opens while you type |
|
|
297
|
-
| `typing.maxWait` | The longest the dialog waits for you to stop typing. `null` waits forever |
|
|
298
|
-
| `mcp.servers` | The policy per MCP server, like `{ "sauron": "deny" }`. The row per server on the settings screen writes this one |
|
|
299
|
-
|
|
300
|
-
The `judge` block:
|
|
301
|
-
|
|
302
|
-
| Key | Default | Does |
|
|
303
|
-
| ------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
304
|
-
| `model` | `"jev-latest"` | A name that starts with `jev` goes to TypeSafe. Anything else is a pi model, as `provider/modelId` or a bare id |
|
|
305
|
-
| `policy` | Standard | The rules the judge follows |
|
|
306
|
-
| `alwaysAsk` | `[]` | Patterns the judge never approves, like `"git push*"`. A pattern matches the tool name too, so `mcp__*` catches every MCP call |
|
|
307
|
-
| `rigor` | `"balanced"` | `"cautious"`, `"balanced"`, or `"relaxed"`. Sets `thresholds` and `riskCeiling` |
|
|
308
|
-
| `dryRun` | `false` | Show the verdict, and still ask you |
|
|
309
|
-
| `thresholds` | from `rigor` | Confidence needed to allow / deny. File only. Set here, it wins over `rigor`, and the Rigor row reads `custom` until you pick a rigor |
|
|
310
|
-
| `riskCeiling` | from `rigor` | Highest risk the judge may approve. File only, and it wins over `rigor` the same way |
|
|
311
|
-
| `canDeny` | `true` | A confident no blocks the call. Off, it asks you. File only |
|
|
312
|
-
| `whenUnsure` | `"ask"` | `"ask"`, `"allow"`, or `"deny"`. File only |
|
|
313
|
-
| `whenItFails` | `"ask"` | The same, for a timeout, an error, or a missing key. File only |
|
|
314
|
-
| `noUI` | `false` | Also judge print, JSON, and subagent runs. File only |
|
|
315
|
-
| `rememberApprovals` | `false` | A judge approval becomes always yes for this session. File only |
|
|
316
|
-
| `timeoutMs` | `5000` | How long to wait for an answer. File only |
|
|
317
|
-
| `cache` | `true` | Reuse a verdict for the same call and the same last message in one session. File only |
|
|
318
|
-
|
|
319
|
-
`judge.enabled`, `judge.tools`, and `judge.provider` are gone. A session that finds them removes them from the file and says so. If `judge.enabled` was `true` and no `mode` was set, it writes `"mode": "judge"`.
|
|
320
|
-
|
|
321
|
-
A malformed value falls back and says what it dropped, so a typo never lets more through. Keys under an older name are read under the current one. `PI_CODING_AGENT_DIR` moves the file with the rest of the agent directory.
|
|
322
|
-
|
|
323
|
-
If `~/.pi/agent/extensions/pi-ask-permission/config.json` exists, the next session reads it, writes what you set there into the shared settings, and renames it to `config.json.bak`.
|
|
324
|
-
|
|
325
|
-
### How a call is decided
|
|
326
|
-
|
|
327
|
-
The first step that answers wins.
|
|
328
|
-
|
|
329
|
-
1. **Always yes** matches the tool and level: run it.
|
|
330
|
-
2. **Codemode**: `codemode` and `tool_search` themselves run. Every call inside is decided on its own.
|
|
331
|
-
3. **Workspace**: a call outside `workspace.roots` asks you, or is blocked with this session's outside set to `deny`. Nothing below can approve it. The call goes on with `allow`, or when every path it reaches is in a folder you opened.
|
|
332
|
-
4. **MCP**: the policy of the server. `deny` blocks, `allow` runs, `ask` asks, and `trust hints` decides nothing here.
|
|
333
|
-
5. **Read-only hint**: the tool declares that it only reads, run it.
|
|
334
|
-
6. **Mode**: `full` runs it, `edits` and `judge` run an edit.
|
|
335
|
-
7. **Allow list**: the tool is in `allow`, run it.
|
|
336
|
-
8. **Read-only bash**: the command only reads, and its paths can be read, run it.
|
|
337
|
-
9. **Judge**, in the `judge` mode: a confident yes runs it, a confident no blocks it.
|
|
338
|
-
10. **No UI**: `noUI` decides.
|
|
339
|
-
11. **Edit check**: an `edit` that cannot apply is blocked with pi's own error, so you never approve a failure.
|
|
340
|
-
12. **You**, in the dialog.
|
|
341
|
-
|
|
342
|
-
The judge answers three questions: a verdict, how reversible the call is, and whether it touches secrets. Code combines them into `risk = 0.6 × reversibility + 0.4 × sensitive` and approves only when the verdict is `allow`, confidence clears `thresholds.allow`, and risk is at most `riskCeiling`. The judge treats the tool call as data, so a command cannot talk its way past the policy or `alwaysAsk`.
|
|
343
|
-
|
|
344
|
-
### Limits
|
|
345
|
-
|
|
346
|
-
- Always yes matches text. `cd /repo && pnpm test` offers `cd`, `cd /repo`, and the whole line, not `pnpm test`.
|
|
347
|
-
- A bash path hidden behind `$HOME`, `$SECRET`, or `"$@"` counts as outside in `manual` and `edits`. In `judge` the judge decides it, even when the command only reads. In `full` it runs. Redirects to `/dev/null` and the other device files stay inside.
|
|
348
|
-
- The read-only check is a classifier, not a sandbox. It trusts the command name as written and does not resolve `PATH`. It refuses anything it cannot prove harmless, so a few safe commands still ask.
|
|
349
|
-
- The judge is a model, and it can be wrong. It sees the tool call and your last message, so do not judge calls or messages that carry secrets you would not send to its provider.
|
|
350
|
-
- Pi's `codemode` tool runs a script that calls other tools. The script itself does not ask, and every call it makes goes through the same steps on its own, saying so in the dialog. The judge reads the arguments of an MCP call as data, like any other call.
|
|
351
|
-
- If you want deterministic rules and no human in the loop, use a sandbox instead.
|
|
352
|
-
|
|
353
|
-
### For other extensions
|
|
354
|
-
|
|
355
|
-
Every decision goes out on `pi.events`:
|
|
356
|
-
|
|
357
|
-
```ts
|
|
358
|
-
pi.events.on("pi-ask-permission:decided", (decided) => {
|
|
359
|
-
// { toolCallId, toolName, summary, action: "allow" | "block", by, reason?, note? }
|
|
360
|
-
});
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
`by` names the step above that decided, `you` for the dialog, or `no UI`. Dialog answers are also saved in the session as `pi-ask-permission:answer` entries.
|
|
364
|
-
|
|
365
|
-
A custom tool can say what it touches, so the workspace and the `edits` mode treat it like `edit`. Emit from `session_start`, after every extension has loaded:
|
|
366
|
-
|
|
367
|
-
```ts
|
|
368
|
-
pi.on("session_start", () => {
|
|
369
|
-
pi.events.emit("pi-ask-permission:tool", {
|
|
370
|
-
name: "apply_patch",
|
|
371
|
-
edits: true,
|
|
372
|
-
paths: (input) => input.files,
|
|
373
|
-
});
|
|
374
|
-
});
|
|
375
|
-
```
|
|
179
|
+
## Limits
|
|
376
180
|
|
|
377
|
-
|
|
181
|
+
- Always yes matches text. `cd /repo && pnpm test` offers `cd`, `cd /repo` and the whole line, not `pnpm test`.
|
|
182
|
+
- The read-only check is a classifier, not a sandbox. It refuses anything it cannot prove harmless, so a few safe commands still ask.
|
|
183
|
+
- The judge is a model and can be wrong. It sees the tool call and your last message, so do not judge calls that carry secrets you would not send to its provider.
|
|
184
|
+
- For deterministic rules and no human in the loop, use a sandbox.
|
|
378
185
|
|
|
379
|
-
|
|
186
|
+
## More
|
|
380
187
|
|
|
381
|
-
|
|
188
|
+
The [reference](docs/reference.md) has every setting, the exact order in which a call is decided, the full list of limits, and the events other extensions can listen to. The settings screen (`Alt+S`) covers the same keys without opening the file.
|
|
382
189
|
|
|
383
190
|
## Contributing
|
|
384
191
|
|
package/assets/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-ask-permission",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "A permission dialog for the Pi coding agent. Approve, always approve, or deny a tool call, and attach a note the model reads with the result.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"approval",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"prepublishOnly": "cd ../.. && bun run verify"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@adeildo/pi-
|
|
52
|
+
"@adeildo/pi-ask-questions": "5.1.0",
|
|
53
|
+
"@adeildo/pi-kit": "5.1.0",
|
|
53
54
|
"shell-quote": "^1.10.0"
|
|
54
55
|
},
|
|
55
56
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { questions } from "@adeildo/pi-ask-questions";
|
|
1
2
|
import { createApp, defineFeature } from "@adeildo/pi-kit";
|
|
2
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
|
|
@@ -38,5 +39,7 @@ export const permission = defineFeature({
|
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
export default function piAskPermission(pi: ExtensionAPI): void {
|
|
41
|
-
|
|
42
|
+
// The dialog is the questions feature's. It comes with the package, and the kit keeps it from
|
|
43
|
+
// running twice when the questions package or the harness is installed as well.
|
|
44
|
+
createApp(pi, { name: "pi-ask-permission" }).use(questions).use(permission).build();
|
|
42
45
|
}
|
package/src/pi/events.ts
CHANGED
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
resetJudgeHealth,
|
|
39
39
|
type SessionState,
|
|
40
40
|
} from "#pi/session.ts";
|
|
41
|
-
import { AskDialog } from "#ui/dialog.ts";
|
|
42
41
|
import { appendJudgeEntry } from "#ui/judge-entry.ts";
|
|
43
42
|
import { askThroughQuestions, type AskExtras } from "#ui/questions.ts";
|
|
44
43
|
import { askViaSelector } from "#ui/selector.ts";
|
|
@@ -267,33 +266,12 @@ async function ask(
|
|
|
267
266
|
extras: AskExtras,
|
|
268
267
|
): Promise<DialogAnswer> {
|
|
269
268
|
const { toolName, target } = call;
|
|
270
|
-
//
|
|
269
|
+
// The questions feature draws the dialog. Without it, and in RPC hosts that cannot draw a
|
|
270
|
+
// terminal component, the host's own selector asks.
|
|
271
271
|
if (ctx.mode === "tui" && canAsk(pi.events)) {
|
|
272
272
|
const answer = await askThroughQuestions(pi.events, call, extras);
|
|
273
273
|
if (answer !== undefined) return answer;
|
|
274
274
|
}
|
|
275
|
-
if (ctx.mode === "tui") {
|
|
276
|
-
try {
|
|
277
|
-
const answer = await ctx.ui.custom<DialogAnswer>(
|
|
278
|
-
(tui, theme, keybindings, done) =>
|
|
279
|
-
new AskDialog({
|
|
280
|
-
theme,
|
|
281
|
-
toolName,
|
|
282
|
-
target,
|
|
283
|
-
mcp: call.mcp,
|
|
284
|
-
hints: call.hints,
|
|
285
|
-
nested: call.nested,
|
|
286
|
-
...extras,
|
|
287
|
-
keybindings,
|
|
288
|
-
requestRender: () => tui.requestRender(),
|
|
289
|
-
complete: done,
|
|
290
|
-
}),
|
|
291
|
-
);
|
|
292
|
-
if (answer) return answer;
|
|
293
|
-
} catch {
|
|
294
|
-
// The plain selector, not an open gate.
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
275
|
|
|
298
276
|
return askViaSelector(ctx, toolName, target, extras.offer);
|
|
299
277
|
}
|
package/src/ui/questions.ts
CHANGED
|
@@ -56,13 +56,22 @@ function firstQuestion(call: Call, extras: AskExtras): AskQuestion {
|
|
|
56
56
|
]
|
|
57
57
|
: []),
|
|
58
58
|
...offerOptions(extras.offer),
|
|
59
|
-
{
|
|
59
|
+
{
|
|
60
|
+
label: DENY,
|
|
61
|
+
description: "Block it. A note on this answer becomes the reason the model reads.",
|
|
62
|
+
},
|
|
60
63
|
];
|
|
61
64
|
|
|
62
65
|
// Every option offers the same preview, whichever row the cursor is on.
|
|
63
66
|
if (extras.diff !== undefined) for (const option of options) option.preview = extras.diff;
|
|
64
67
|
|
|
65
|
-
|
|
68
|
+
// The reason for a no is a note on it. A row of its own for words would say the same thing twice.
|
|
69
|
+
return {
|
|
70
|
+
header: "permission",
|
|
71
|
+
question: questionText(call, extras.reason),
|
|
72
|
+
options,
|
|
73
|
+
typed: false,
|
|
74
|
+
};
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
function offerOptions(offer: FolderOffer | undefined): { label: string; description: string }[] {
|
|
@@ -78,8 +87,21 @@ function offerOptions(offer: FolderOffer | undefined): { label: string; descript
|
|
|
78
87
|
];
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
/** A heredoc or a long payload would fill the screen; a few lines say what the call is. */
|
|
91
|
+
const SUMMARY_LINES = 3;
|
|
92
|
+
|
|
93
|
+
function clipSummary(summary: string): string[] {
|
|
94
|
+
const lines = summary.split("\n");
|
|
95
|
+
if (lines.length <= SUMMARY_LINES) return lines;
|
|
96
|
+
const more = lines.length - SUMMARY_LINES;
|
|
97
|
+
return [...lines.slice(0, SUMMARY_LINES), `\u2026 ${more} more ${more === 1 ? "line" : "lines"}`];
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
function questionText(call: Call, reason: string | undefined): string {
|
|
82
|
-
const lines = [
|
|
101
|
+
const lines = [
|
|
102
|
+
`${whereOf(call)} wants to run:`,
|
|
103
|
+
...clipSummary(call.target.summary).map((line) => ` ${line}`),
|
|
104
|
+
];
|
|
83
105
|
if (reason !== undefined) lines.push("", `\u25b2 ${reason}`);
|
|
84
106
|
return lines.join("\n");
|
|
85
107
|
}
|
|
@@ -111,6 +133,7 @@ async function remember(
|
|
|
111
133
|
const picked = await askQuestions(events, [
|
|
112
134
|
{
|
|
113
135
|
header: "remember",
|
|
136
|
+
typed: false,
|
|
114
137
|
question: `Always say yes to which calls, from ${call.toolName}?`,
|
|
115
138
|
options: levels.map((level, index) => ({
|
|
116
139
|
label: level,
|
|
@@ -133,6 +156,7 @@ async function askScope(events: ExtensionAPI["events"], question: string): Promi
|
|
|
133
156
|
const result = await askQuestions(events, [
|
|
134
157
|
{
|
|
135
158
|
header: "where",
|
|
159
|
+
typed: false,
|
|
136
160
|
question,
|
|
137
161
|
options: SCOPES.map((scope) => ({
|
|
138
162
|
label: SCOPE_LABEL[scope],
|
package/src/ui/clipboard.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
interface ClipboardImage {
|
|
7
|
-
bytes: Uint8Array;
|
|
8
|
-
mimeType: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface ClipboardReader {
|
|
12
|
-
readClipboardImage: () => Promise<ClipboardImage | null>;
|
|
13
|
-
readClipboardText: () => Promise<string | null>;
|
|
14
|
-
extensionForImageMimeType: (mimeType: string) => string | null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let loaded: Promise<ClipboardReader | null> | undefined;
|
|
18
|
-
|
|
19
|
-
function loadClipboard(): Promise<ClipboardReader | null> {
|
|
20
|
-
loaded ??= (async () => {
|
|
21
|
-
try {
|
|
22
|
-
const root = import.meta.resolve("@earendil-works/pi-coding-agent");
|
|
23
|
-
const images = await import(new URL("./utils/clipboard-image.js", root).href);
|
|
24
|
-
const text = await import(new URL("./utils/clipboard.js", root).href);
|
|
25
|
-
return {
|
|
26
|
-
readClipboardImage: images.readClipboardImage,
|
|
27
|
-
extensionForImageMimeType: images.extensionForImageMimeType,
|
|
28
|
-
readClipboardText: text.readClipboardText,
|
|
29
|
-
};
|
|
30
|
-
} catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
})();
|
|
34
|
-
|
|
35
|
-
return loaded;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function readClipboard(): Promise<string | null> {
|
|
39
|
-
const clipboard = await loadClipboard();
|
|
40
|
-
if (!clipboard) return null;
|
|
41
|
-
|
|
42
|
-
const image = await clipboard.readClipboardImage();
|
|
43
|
-
if (image) {
|
|
44
|
-
const extension = clipboard.extensionForImageMimeType(image.mimeType) ?? "png";
|
|
45
|
-
const path = join(tmpdir(), `pi-ask-permission-${randomUUID()}.${extension}`);
|
|
46
|
-
writeFileSync(path, Buffer.from(image.bytes));
|
|
47
|
-
return path;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return clipboard.readClipboardText();
|
|
51
|
-
}
|
package/src/ui/dialog.ts
DELETED
|
@@ -1,507 +0,0 @@
|
|
|
1
|
-
import { renderDiff, type Theme, type ToolAnnotations } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
type Component,
|
|
4
|
-
type Focusable,
|
|
5
|
-
Input,
|
|
6
|
-
Key,
|
|
7
|
-
type KeybindingsManager,
|
|
8
|
-
matchesKey,
|
|
9
|
-
truncateToWidth,
|
|
10
|
-
visibleWidth,
|
|
11
|
-
wrapTextWithAnsi,
|
|
12
|
-
} from "@earendil-works/pi-tui";
|
|
13
|
-
|
|
14
|
-
import { type Scope, SCOPE_LABEL, SCOPES } from "#core/always-yes.ts";
|
|
15
|
-
import type { DialogAnswer, FolderOffer } from "#core/answer.ts";
|
|
16
|
-
import { describeHints, type McpCall } from "#core/mcp.ts";
|
|
17
|
-
import type { CallDescriptor } from "#core/tools.ts";
|
|
18
|
-
import { readClipboard } from "#ui/clipboard.ts";
|
|
19
|
-
import { type Choice, choicesFor, openLabel } from "#ui/decision-options.ts";
|
|
20
|
-
import {
|
|
21
|
-
cleanPaste,
|
|
22
|
-
expandPastes,
|
|
23
|
-
PASTE_END,
|
|
24
|
-
PASTE_START,
|
|
25
|
-
pasteMarker,
|
|
26
|
-
shouldCollapse,
|
|
27
|
-
} from "#ui/paste.ts";
|
|
28
|
-
|
|
29
|
-
type Phase = "menu" | "levels";
|
|
30
|
-
|
|
31
|
-
const TITLE_PREFIX = "\u256d\u2500 ";
|
|
32
|
-
const TITLE_SUFFIX = "\u256e";
|
|
33
|
-
// The 1 is the space between the title and its dashes.
|
|
34
|
-
const TITLE_CHROME_WIDTH = visibleWidth(TITLE_PREFIX) + 1 + visibleWidth(TITLE_SUFFIX);
|
|
35
|
-
|
|
36
|
-
const SUMMARY_ROWS = 3;
|
|
37
|
-
const DIFF_ROWS = 16;
|
|
38
|
-
|
|
39
|
-
interface AskDialogOptions {
|
|
40
|
-
theme: Theme;
|
|
41
|
-
toolName: string;
|
|
42
|
-
target: CallDescriptor;
|
|
43
|
-
/** Set for a call to an MCP server. */
|
|
44
|
-
mcp?: McpCall;
|
|
45
|
-
/** What the tool declares about itself, whatever registered it. */
|
|
46
|
-
hints?: ToolAnnotations;
|
|
47
|
-
/** Issued by another tool, as in a codemode script. */
|
|
48
|
-
nested?: boolean;
|
|
49
|
-
diff?: string;
|
|
50
|
-
/** Why the call asks, when a layer said. */
|
|
51
|
-
reason?: string;
|
|
52
|
-
offer?: FolderOffer;
|
|
53
|
-
keybindings: KeybindingsManager;
|
|
54
|
-
requestRender: () => void;
|
|
55
|
-
complete: (answer: DialogAnswer) => void;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export class AskDialog implements Component, Focusable {
|
|
59
|
-
private readonly theme: Theme;
|
|
60
|
-
private readonly title: string;
|
|
61
|
-
private readonly mcp: McpCall | undefined;
|
|
62
|
-
private readonly hints: ToolAnnotations;
|
|
63
|
-
private readonly nested: boolean;
|
|
64
|
-
private readonly target: CallDescriptor;
|
|
65
|
-
private readonly diff: string | undefined;
|
|
66
|
-
private readonly reason: string | undefined;
|
|
67
|
-
private readonly offer: FolderOffer | undefined;
|
|
68
|
-
private readonly choices: Choice[];
|
|
69
|
-
private readonly requestRender: () => void;
|
|
70
|
-
private readonly complete: (answer: DialogAnswer) => void;
|
|
71
|
-
private readonly keybindings: KeybindingsManager;
|
|
72
|
-
|
|
73
|
-
private readonly noteInputs: Input[];
|
|
74
|
-
|
|
75
|
-
private readonly pastes = new Map<number, string>();
|
|
76
|
-
private pasteId = 0;
|
|
77
|
-
private pasteBuffer = "";
|
|
78
|
-
|
|
79
|
-
private phase: Phase = "menu";
|
|
80
|
-
private selected = 0;
|
|
81
|
-
private levelIndex = 0;
|
|
82
|
-
private scopeIndex = 0;
|
|
83
|
-
private pending: Choice | null = null;
|
|
84
|
-
private noteIndex: number | null = null;
|
|
85
|
-
private folderIndex = 0;
|
|
86
|
-
private openScope: "session" | "project" = "session";
|
|
87
|
-
|
|
88
|
-
private pendingNote: string | undefined;
|
|
89
|
-
|
|
90
|
-
private get activeNoteInput(): Input | undefined {
|
|
91
|
-
return this.noteIndex === null ? undefined : this.noteInputs[this.noteIndex];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
private focusedFlag = false;
|
|
95
|
-
get focused(): boolean {
|
|
96
|
-
return this.focusedFlag;
|
|
97
|
-
}
|
|
98
|
-
set focused(value: boolean) {
|
|
99
|
-
this.focusedFlag = value;
|
|
100
|
-
for (const input of this.noteInputs) input.focused = value;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
constructor(options: AskDialogOptions) {
|
|
104
|
-
this.theme = options.theme;
|
|
105
|
-
this.title = options.mcp ? `${options.mcp.server}:${options.mcp.tool}` : options.toolName;
|
|
106
|
-
this.mcp = options.mcp;
|
|
107
|
-
this.hints = options.hints ?? {};
|
|
108
|
-
this.nested = options.nested === true;
|
|
109
|
-
this.target = options.target;
|
|
110
|
-
this.diff = options.diff;
|
|
111
|
-
this.reason = options.reason;
|
|
112
|
-
this.offer = options.offer;
|
|
113
|
-
this.choices = choicesFor(options.offer);
|
|
114
|
-
this.folderIndex = options.offer?.suggested ?? 0;
|
|
115
|
-
// Reading a sibling folder is the common case, so its answer is where the cursor starts.
|
|
116
|
-
if (options.offer?.access === "read") this.selected = 1;
|
|
117
|
-
this.requestRender = options.requestRender;
|
|
118
|
-
this.complete = options.complete;
|
|
119
|
-
this.keybindings = options.keybindings;
|
|
120
|
-
|
|
121
|
-
this.noteInputs = [...this.choices.keys()].map((index) => {
|
|
122
|
-
const input = new Input({ prompt: "", placeholder: "" });
|
|
123
|
-
input.onSubmit = () => this.choose(index);
|
|
124
|
-
input.onEscape = () => {
|
|
125
|
-
this.noteIndex = null;
|
|
126
|
-
};
|
|
127
|
-
return input;
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
handleInput(data: string): void {
|
|
132
|
-
try {
|
|
133
|
-
this.dispatch(data);
|
|
134
|
-
} finally {
|
|
135
|
-
this.requestRender();
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
invalidate(): void {
|
|
140
|
-
for (const input of this.noteInputs) input.invalidate();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
render(width: number): string[] {
|
|
144
|
-
const inner = Math.max(1, width - 4);
|
|
145
|
-
const lines: string[] = [
|
|
146
|
-
...this.summaryLines(inner),
|
|
147
|
-
...this.sourceLines(),
|
|
148
|
-
...this.reasonLines(),
|
|
149
|
-
...this.diffLines(),
|
|
150
|
-
];
|
|
151
|
-
lines.push("");
|
|
152
|
-
|
|
153
|
-
if (this.phase === "levels") {
|
|
154
|
-
lines.push(...this.levelLines());
|
|
155
|
-
} else {
|
|
156
|
-
for (const [index, option] of this.choices.entries()) {
|
|
157
|
-
lines.push(this.renderOption(option, index, inner));
|
|
158
|
-
}
|
|
159
|
-
lines.push("");
|
|
160
|
-
lines.push(this.theme.fg("dim", this.hint()));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return this.frame(lines, width, inner, this.title);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
private dispatch(data: string): void {
|
|
167
|
-
if (this.phase === "menu" && this.noteIndex !== null) {
|
|
168
|
-
if (matchesKey(data, Key.up)) this.moveNote(-1);
|
|
169
|
-
else if (matchesKey(data, Key.down)) this.moveNote(1);
|
|
170
|
-
else if (matchesKey(data, Key.tab)) this.noteIndex = null;
|
|
171
|
-
else this.handleNoteInput(data);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (this.phase === "levels") {
|
|
176
|
-
if (matchesKey(data, Key.up)) this.levelIndex = Math.max(0, this.levelIndex - 1);
|
|
177
|
-
else if (matchesKey(data, Key.down))
|
|
178
|
-
this.levelIndex = Math.min(this.target.levels.length - 1, this.levelIndex + 1);
|
|
179
|
-
else if (matchesKey(data, Key.tab)) this.scopeIndex = (this.scopeIndex + 1) % SCOPES.length;
|
|
180
|
-
else if (matchesKey(data, Key.enter)) this.confirmLevel();
|
|
181
|
-
else if (matchesKey(data, Key.escape)) this.phase = "menu";
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (matchesKey(data, Key.up)) {
|
|
186
|
-
this.moveSelection(-1);
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
if (matchesKey(data, Key.down)) {
|
|
190
|
-
this.moveSelection(1);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
if (matchesKey(data, Key.tab)) {
|
|
194
|
-
this.noteIndex = this.selected;
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
if (matchesKey(data, Key.enter)) {
|
|
198
|
-
this.choose(this.selected);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
if (matchesKey(data, Key.escape)) {
|
|
202
|
-
this.complete({ decision: "deny" });
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
if (this.onOpenChoice && this.adjustFolder(data)) return;
|
|
206
|
-
|
|
207
|
-
const picked = this.choices.findIndex((option) => option.key === data);
|
|
208
|
-
if (picked >= 0) this.selected = picked;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private get folder(): string | undefined {
|
|
212
|
-
return this.offer?.folders[this.folderIndex];
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private get onOpenChoice(): boolean {
|
|
216
|
-
return this.choices[this.selected]?.open === true;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Left goes up toward the root, right back down toward the paths.
|
|
220
|
-
private adjustFolder(data: string): boolean {
|
|
221
|
-
const last = (this.offer?.folders.length ?? 1) - 1;
|
|
222
|
-
if (matchesKey(data, Key.left)) {
|
|
223
|
-
this.folderIndex = Math.max(0, this.folderIndex - 1);
|
|
224
|
-
return true;
|
|
225
|
-
}
|
|
226
|
-
if (matchesKey(data, Key.right)) {
|
|
227
|
-
this.folderIndex = Math.min(last, this.folderIndex + 1);
|
|
228
|
-
return true;
|
|
229
|
-
}
|
|
230
|
-
if (data === "s") {
|
|
231
|
-
this.openScope = this.openScope === "session" ? "project" : "session";
|
|
232
|
-
return true;
|
|
233
|
-
}
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
private hint(): string {
|
|
238
|
-
if (this.noteIndex !== null) return "\u2191\u2193 pick enter confirm esc back";
|
|
239
|
-
|
|
240
|
-
const pick = `\u2191\u2193 or 1-${this.choices.length} pick`;
|
|
241
|
-
if (!this.onOpenChoice) return `${pick} enter confirm tab note esc deny`;
|
|
242
|
-
|
|
243
|
-
const folders = (this.offer?.folders.length ?? 0) > 1 ? "\u2190\u2192 folder " : "";
|
|
244
|
-
const scope = this.openScope === "session" ? "keep for this project" : "only this session";
|
|
245
|
-
return `${folders}s ${scope} tab note esc deny`;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private moveSelection(delta: number): void {
|
|
249
|
-
const count = this.choices.length;
|
|
250
|
-
this.selected = (this.selected + delta + count) % count;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
private moveNote(delta: number): void {
|
|
254
|
-
if (this.noteIndex === null) return;
|
|
255
|
-
this.moveSelection(delta);
|
|
256
|
-
this.noteIndex = this.selected;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
private choose(index: number): void {
|
|
260
|
-
const option = this.choices[index];
|
|
261
|
-
if (!option) return;
|
|
262
|
-
this.selected = index;
|
|
263
|
-
|
|
264
|
-
const folder = this.folder;
|
|
265
|
-
if (option.open && this.offer && folder !== undefined) {
|
|
266
|
-
this.complete({
|
|
267
|
-
decision: "allow",
|
|
268
|
-
note: this.draftNote(),
|
|
269
|
-
open: { path: folder, access: this.offer.access, scope: this.openScope },
|
|
270
|
-
});
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (option.always) {
|
|
275
|
-
this.pending = option;
|
|
276
|
-
this.pendingNote = this.draftNote();
|
|
277
|
-
this.noteIndex = null;
|
|
278
|
-
this.levelIndex = this.target.levels.length - 1;
|
|
279
|
-
this.scopeIndex = 0;
|
|
280
|
-
this.phase = "levels";
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
this.complete({
|
|
285
|
-
decision: option.decision,
|
|
286
|
-
note: this.draftNote(),
|
|
287
|
-
remember: option.always ? this.target.levels[0] : undefined,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
private confirmLevel(): void {
|
|
292
|
-
const level = this.target.levels[this.levelIndex];
|
|
293
|
-
if (!this.pending || level === undefined) return;
|
|
294
|
-
|
|
295
|
-
this.complete({
|
|
296
|
-
decision: this.pending.decision,
|
|
297
|
-
note: this.pendingNote,
|
|
298
|
-
remember: level,
|
|
299
|
-
scope: this.currentScope,
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
private get currentScope(): Scope {
|
|
304
|
-
return SCOPES[this.scopeIndex] ?? "session";
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private draftNote(): string | undefined {
|
|
308
|
-
const input = this.activeNoteInput;
|
|
309
|
-
if (!input) return undefined;
|
|
310
|
-
|
|
311
|
-
const note = expandPastes(input.getValue(), this.pastes).trim();
|
|
312
|
-
return note || undefined;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
private handleNoteInput(data: string): void {
|
|
316
|
-
if (this.pasteBuffer !== "" || data.includes(PASTE_START)) {
|
|
317
|
-
this.bufferPaste(data);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
|
|
322
|
-
this.pasteClipboard();
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
this.activeNoteInput?.handleInput(data);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
private bufferPaste(data: string): void {
|
|
330
|
-
const start = data.indexOf(PASTE_START);
|
|
331
|
-
const text = this.pasteBuffer + (start === -1 ? data : data.slice(start + PASTE_START.length));
|
|
332
|
-
const end = text.indexOf(PASTE_END);
|
|
333
|
-
|
|
334
|
-
if (end === -1) {
|
|
335
|
-
this.pasteBuffer = text;
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
this.pasteBuffer = "";
|
|
340
|
-
const index = this.noteIndex;
|
|
341
|
-
if (index !== null) this.insertPaste(index, text.slice(0, end));
|
|
342
|
-
|
|
343
|
-
const rest = text.slice(end + PASTE_END.length);
|
|
344
|
-
if (rest) this.handleNoteInput(rest);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
private pasteClipboard(): void {
|
|
348
|
-
const index = this.noteIndex;
|
|
349
|
-
if (index === null) return;
|
|
350
|
-
|
|
351
|
-
void readClipboard().then((text) => {
|
|
352
|
-
if (!text) return;
|
|
353
|
-
this.insertPaste(index, text);
|
|
354
|
-
this.requestRender();
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
private insertPaste(index: number, pastedText: string): void {
|
|
359
|
-
const input = this.noteInputs[index];
|
|
360
|
-
if (!input) return;
|
|
361
|
-
|
|
362
|
-
const text = cleanPaste(pastedText);
|
|
363
|
-
if (shouldCollapse(text)) {
|
|
364
|
-
this.pasteId++;
|
|
365
|
-
this.pastes.set(this.pasteId, text);
|
|
366
|
-
input.handleInput(pasteMarker(this.pasteId, text));
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const spacer = /^[/~.]/.test(text) && /\w$/.test(input.getValue()) ? " " : "";
|
|
371
|
-
input.handleInput(spacer + text);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
private levelLines(): string[] {
|
|
375
|
-
const lines = [this.theme.fg("muted", "always yes for...")];
|
|
376
|
-
|
|
377
|
-
for (const [index, level] of this.target.levels.entries()) {
|
|
378
|
-
const active = index === this.levelIndex;
|
|
379
|
-
const marker = active ? this.theme.fg("accent", "\u276f ") : " ";
|
|
380
|
-
lines.push(marker + this.theme.fg(active ? "accent" : "text", level));
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (this.pendingNote) lines.push(this.theme.fg("muted", `note: ${this.pendingNote}`));
|
|
384
|
-
lines.push("");
|
|
385
|
-
lines.push(
|
|
386
|
-
this.theme.fg("muted", "scope: ") +
|
|
387
|
-
this.theme.fg("accent", SCOPE_LABEL[this.currentScope]) +
|
|
388
|
-
this.theme.fg("dim", " (tab to change)"),
|
|
389
|
-
);
|
|
390
|
-
lines.push("");
|
|
391
|
-
lines.push(this.theme.fg("dim", "\u2191\u2193 depth tab scope enter confirm esc back"));
|
|
392
|
-
return lines;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
private renderOption(option: Choice, index: number, inner: number): string {
|
|
396
|
-
const active = index === this.selected;
|
|
397
|
-
const editing = this.noteIndex === index;
|
|
398
|
-
const input = this.noteInputs[index];
|
|
399
|
-
const draft = input?.getValue().trim() ?? "";
|
|
400
|
-
const marker = active ? this.theme.fg("accent", "\u276f ") : " ";
|
|
401
|
-
const key = this.theme.fg(active ? "accent" : "dim", option.key);
|
|
402
|
-
const prefix = `${marker}${key} `;
|
|
403
|
-
const text = this.labelOf(option);
|
|
404
|
-
|
|
405
|
-
if (!editing && !draft) {
|
|
406
|
-
const head = this.theme.fg(active ? option.tone : "text", text);
|
|
407
|
-
return truncateToWidth(`${prefix}${head}${this.openTags(option, active)}`, inner);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const room = Math.max(1, inner - visibleWidth(prefix));
|
|
411
|
-
const label = truncateToWidth(`${text}, `, room);
|
|
412
|
-
const noteRoom = Math.max(1, room - visibleWidth(label));
|
|
413
|
-
const note = editing
|
|
414
|
-
? (input?.render(noteRoom)[0] ?? "")
|
|
415
|
-
: this.theme.fg("dim", truncateToWidth(draft, noteRoom));
|
|
416
|
-
const head = this.theme.fg(active ? option.tone : "text", label);
|
|
417
|
-
return truncateToWidth(`${prefix}${head}${note}`, inner);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
private labelOf(option: Choice): string {
|
|
421
|
-
const folder = this.folder;
|
|
422
|
-
if (!option.open || !this.offer || folder === undefined) return option.label;
|
|
423
|
-
return openLabel(this.offer, folder);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// The scope shows only when it is not the default, and the repo tag only on the focused row.
|
|
427
|
-
private openTags(option: Choice, active: boolean): string {
|
|
428
|
-
if (!option.open || !this.offer) return "";
|
|
429
|
-
const tags: string[] = [];
|
|
430
|
-
if (this.openScope === "project") tags.push(this.theme.fg("accent", "for this project"));
|
|
431
|
-
if (active && this.folder !== undefined && this.folder === this.offer.repoRoot)
|
|
432
|
-
tags.push(this.theme.fg("dim", "repo root"));
|
|
433
|
-
return tags.length === 0 ? "" : ` ${tags.join(" ")}`;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Where the call comes from, when that is worth saying: the server's own hint about the tool, and
|
|
437
|
-
// the script that issued it.
|
|
438
|
-
private sourceLines(): string[] {
|
|
439
|
-
const parts: string[] = [];
|
|
440
|
-
if (this.mcp) parts.push(`${this.mcp.server}: ${describeHints(this.hints)}`);
|
|
441
|
-
if (this.nested) parts.push("from a codemode script");
|
|
442
|
-
return parts.length === 0 ? [] : [this.theme.fg("dim", parts.join(" "))];
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
private reasonLines(): string[] {
|
|
446
|
-
let text = this.reason;
|
|
447
|
-
if (this.offer?.access === "read") text = "reads outside the workspace";
|
|
448
|
-
if (this.offer?.access === "write") text = "writes outside the workspace";
|
|
449
|
-
return text === undefined ? [] : [this.theme.fg("warning", `\u25b2 ${text}`)];
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
private diffLines(): string[] {
|
|
453
|
-
if (!this.diff) return [];
|
|
454
|
-
|
|
455
|
-
const lines = renderDiff(this.diff).split("\n");
|
|
456
|
-
const shown = lines.slice(0, DIFF_ROWS);
|
|
457
|
-
const hidden = lines.length - shown.length;
|
|
458
|
-
if (hidden > 0) shown.push(this.theme.fg("dim", `\u2026 ${hidden} more lines`));
|
|
459
|
-
return ["", ...shown];
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
private summaryLines(inner: number): string[] {
|
|
463
|
-
const wrapped = wrapTextWithAnsi(this.target.summary || "(no input)", inner);
|
|
464
|
-
const shown = wrapped.slice(0, SUMMARY_ROWS);
|
|
465
|
-
const elideLast = wrapped.length > shown.length;
|
|
466
|
-
|
|
467
|
-
return shown.map((line, index) => {
|
|
468
|
-
const text =
|
|
469
|
-
elideLast && index === shown.length - 1
|
|
470
|
-
? `${truncateToWidth(line, Math.max(0, inner - 3))}...`
|
|
471
|
-
: line;
|
|
472
|
-
return this.theme.fg("muted", text);
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
private frame(lines: string[], width: number, inner: number, title: string): string[] {
|
|
477
|
-
const out: string[] = [this.topBorder(width, title)];
|
|
478
|
-
|
|
479
|
-
for (const line of lines) {
|
|
480
|
-
const clipped = truncateToWidth(line, inner);
|
|
481
|
-
const pad = " ".repeat(Math.max(0, inner - visibleWidth(clipped)));
|
|
482
|
-
out.push(
|
|
483
|
-
`${this.theme.fg("border", "\u2502")} ${clipped}${pad} ${this.theme.fg("border", "\u2502")}`,
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
out.push(this.theme.fg("border", `\u2570${"\u2500".repeat(Math.max(0, width - 2))}\u256f`));
|
|
488
|
-
return out;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
private topBorder(width: number, title: string): string {
|
|
492
|
-
const room = Math.max(0, width - TITLE_CHROME_WIDTH);
|
|
493
|
-
// The colour tells the dialog from the call, so the two words need nothing between them.
|
|
494
|
-
const label = truncateToWidth(
|
|
495
|
-
this.theme.fg("muted", "permission ") + this.theme.fg("accent", title),
|
|
496
|
-
Math.max(0, room - 3),
|
|
497
|
-
"...",
|
|
498
|
-
);
|
|
499
|
-
const dashes = Math.max(0, room - visibleWidth(label));
|
|
500
|
-
|
|
501
|
-
return (
|
|
502
|
-
this.theme.fg("border", TITLE_PREFIX) +
|
|
503
|
-
label +
|
|
504
|
-
this.theme.fg("border", ` ${"\u2500".repeat(dashes)}${TITLE_SUFFIX}`)
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
}
|
package/src/ui/paste.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
export const PASTE_START = "\x1b[200~";
|
|
2
|
-
export const PASTE_END = "\x1b[201~";
|
|
3
|
-
|
|
4
|
-
const MARKER = /\[paste #(\d+)(?:\s[^\]]*)?\]/g;
|
|
5
|
-
const MAX_CHARS = 1000;
|
|
6
|
-
|
|
7
|
-
export function cleanPaste(text: string): string {
|
|
8
|
-
return text
|
|
9
|
-
.replace(/\r\n?/g, "\n")
|
|
10
|
-
.replace(/\t/g, " ")
|
|
11
|
-
.split("")
|
|
12
|
-
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
|
|
13
|
-
.join("");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function shouldCollapse(text: string): boolean {
|
|
17
|
-
return text.includes("\n") || text.length > MAX_CHARS;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function pasteMarker(id: number, text: string): string {
|
|
21
|
-
const lines = text.split("\n").length;
|
|
22
|
-
return lines > 1 ? `[paste #${id} +${lines} lines]` : `[paste #${id} ${text.length} chars]`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function expandPastes(text: string, pastes: Map<number, string>): string {
|
|
26
|
-
return text.replace(MARKER, (match, id: string) => pastes.get(Number(id)) ?? match);
|
|
27
|
-
}
|