pi-prompt-suggestions 0.1.2
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 +26 -0
- package/README.md +90 -0
- package/docs/plan.md +404 -0
- package/package.json +56 -0
- package/prompts/suggestion-system-prompt.md +34 -0
- package/src/index.ts +504 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Loads the suggestion system prompt from `prompts/suggestion-system-prompt.md`.
|
|
6
|
+
- Adds global/project config files for `enabled`, `acceptTab`, `display`, `model`, `maxTokens`, and `maxChars`.
|
|
7
|
+
- Supports Enter on an empty editor to submit the visible suggestion immediately.
|
|
8
|
+
- Adds `display` config with default `ghost` mode and `belowEditor` fallback.
|
|
9
|
+
- Adds opt-in `acceptTab` config for accepting visible suggestions with Tab.
|
|
10
|
+
- Removes test-only startup suggestion and command from packaged extension code.
|
|
11
|
+
- Adds stricter output filtering for meta text, labels, error text, markdown formatting, word count, assistant voice, and evaluative replies.
|
|
12
|
+
- Adds README disclaimer.
|
|
13
|
+
|
|
14
|
+
## 0.1.1 - 2026-05-23
|
|
15
|
+
|
|
16
|
+
- Moved the suggestion system prompt into a packaged markdown file.
|
|
17
|
+
|
|
18
|
+
## 0.1.0 - 2026-05-23
|
|
19
|
+
|
|
20
|
+
Initial release.
|
|
21
|
+
|
|
22
|
+
- Suggests short next prompts after agent responses.
|
|
23
|
+
- Displays suggestions below the editor.
|
|
24
|
+
- Accepts suggestions with Right Arrow when the editor is empty.
|
|
25
|
+
- Preserves Tab/autocomplete behavior.
|
|
26
|
+
- Includes a local `test/` harness and automated helper tests.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# pi-prompt-suggestions
|
|
2
|
+
|
|
3
|
+
Pi extension that suggests a natural next prompt after an agent response.
|
|
4
|
+
|
|
5
|
+
Disclaimer: this is clanker slop.
|
|
6
|
+
|
|
7
|
+
Behavior:
|
|
8
|
+
|
|
9
|
+
- Generate a short next-prompt suggestion after `agent_end`.
|
|
10
|
+
- Show it as ghost text in the input editor by default.
|
|
11
|
+
- Optionally show it below the input editor.
|
|
12
|
+
- Accept it with Right Arrow when the editor is empty.
|
|
13
|
+
- Submit it immediately with Enter when the editor is empty.
|
|
14
|
+
- Optionally accept it with Tab when the editor is empty.
|
|
15
|
+
- Leave Tab/autocomplete behavior unchanged unless `acceptTab` is enabled.
|
|
16
|
+
- Reject common bad model outputs such as questions, meta text, labels, errors, markdown formatting, evaluative replies, and overlong suggestions.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
From GitHub:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install git:github.com/SteelDynamite/pi-prompt-suggestions@master
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Latest release tag currently points to the older below-editor implementation:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install git:github.com/SteelDynamite/pi-prompt-suggestions@v0.1.1
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
After npm publishing:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:pi-prompt-suggestions@0.1.1
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
By default, suggestions use the active Pi model.
|
|
41
|
+
|
|
42
|
+
Optional global config:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
~/.pi/agent/extensions/prompt-suggestions.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Optional project config:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
.pi/prompt-suggestions.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Project config overrides global config.
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"enabled": true,
|
|
59
|
+
"acceptTab": false,
|
|
60
|
+
"display": "ghost",
|
|
61
|
+
"model": "openai/gpt-5-mini",
|
|
62
|
+
"maxTokens": 256,
|
|
63
|
+
"maxChars": 80
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`acceptTab` defaults to `false`. When `true`, Tab accepts the visible suggestion into an empty editor without submitting; this can steal Tab from autocomplete while a suggestion is visible.
|
|
68
|
+
|
|
69
|
+
`display` can be `ghost` or `belowEditor`; default is `ghost`.
|
|
70
|
+
|
|
71
|
+
`model` uses `provider/modelId`. If omitted or invalid, the extension falls back to the active Pi model.
|
|
72
|
+
|
|
73
|
+
The suggestion-generation system prompt lives in [`prompts/suggestion-system-prompt.md`](prompts/suggestion-system-prompt.md). Edit that file if you want to change the generation instructions before packaging/installing from source.
|
|
74
|
+
|
|
75
|
+
See [`docs/plan.md`](docs/plan.md) for the implementation plan.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
Run the validation suite:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm run validate
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Try the extension interactively from the local test project:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
cd test
|
|
89
|
+
pi
|
|
90
|
+
```
|
package/docs/plan.md
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# Pi Next-Prompt Suggestions Extension Plan
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Add Claude Code-like suggested next prompts to pi as an extension, without changing pi core.
|
|
6
|
+
|
|
7
|
+
The extension will:
|
|
8
|
+
|
|
9
|
+
- Generate a short suggested next user prompt after an agent loop finishes.
|
|
10
|
+
- Display the suggestion as inline ghost text by default, with a below-editor fallback mode.
|
|
11
|
+
- Accept the suggestion with Right Arrow when the input editor is empty.
|
|
12
|
+
- Submit the suggestion with Enter when the input editor is empty.
|
|
13
|
+
- Optionally accept the suggestion with Tab when enabled.
|
|
14
|
+
- Avoid interfering with existing Tab/autocomplete behavior by default.
|
|
15
|
+
|
|
16
|
+
## UX
|
|
17
|
+
|
|
18
|
+
When pi finishes responding, show a dim ghost hint in the editor:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
run the tests
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Fallback `belowEditor` mode shows:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
→ run the tests
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Behavior:
|
|
31
|
+
|
|
32
|
+
- Press `Right Arrow` on an empty input to fill the editor with the suggestion without submitting.
|
|
33
|
+
- Press `Enter` on an empty input to submit the suggestion immediately.
|
|
34
|
+
- If `acceptTab` is enabled, press `Tab` on an empty input to fill the editor without submitting.
|
|
35
|
+
- Start typing anything else to clear the suggestion.
|
|
36
|
+
- Submit normally with Enter after accepting/editing.
|
|
37
|
+
- No suggestion is shown if the next step is not obvious.
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
### Extension responsibilities
|
|
42
|
+
|
|
43
|
+
1. Subscribe to `agent_end`.
|
|
44
|
+
2. Generate a suggestion asynchronously.
|
|
45
|
+
3. Render the suggestion as editor ghost text, or using `ctx.ui.setWidget()` in fallback mode.
|
|
46
|
+
4. Replace the editor with a small `CustomEditor` subclass via `ctx.ui.setEditorComponent()`.
|
|
47
|
+
5. Accept the suggestion on Right Arrow only when the editor is empty.
|
|
48
|
+
6. Submit the suggestion on Enter only when the editor is empty.
|
|
49
|
+
7. Optionally accept the suggestion on Tab only when the editor is empty and `acceptTab` is enabled.
|
|
50
|
+
8. Clear stale suggestions on user input, new agent turns, session changes, and reload/shutdown.
|
|
51
|
+
|
|
52
|
+
### Pi core responsibilities
|
|
53
|
+
|
|
54
|
+
No required core changes.
|
|
55
|
+
|
|
56
|
+
Optional future core improvement:
|
|
57
|
+
|
|
58
|
+
- Add native editor suggestion primitives:
|
|
59
|
+
- `ctx.ui.setEditorSuggestion(text | undefined)`
|
|
60
|
+
- render inline ghost text
|
|
61
|
+
- accept with Tab/Right Arrow
|
|
62
|
+
|
|
63
|
+
## Files
|
|
64
|
+
|
|
65
|
+
Current package layout:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
src/index.ts # extension implementation
|
|
69
|
+
prompts/suggestion-system-prompt.md # model instruction prompt
|
|
70
|
+
README.md # user docs
|
|
71
|
+
docs/plan.md # implementation notes
|
|
72
|
+
tests/*.test.ts # automated helper tests
|
|
73
|
+
test/.pi/extensions/*.ts # local interactive test harness
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Installed package entrypoint is declared in `package.json`:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"pi": {
|
|
81
|
+
"extensions": ["./src/index.ts"]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Implementation Steps
|
|
87
|
+
|
|
88
|
+
### 1. Create extension state
|
|
89
|
+
|
|
90
|
+
Maintain module-level/session-level state:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
let suggestion: string | undefined;
|
|
94
|
+
let generationId = 0;
|
|
95
|
+
let lastCtx: ExtensionContext | undefined;
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
State rules:
|
|
99
|
+
|
|
100
|
+
- `suggestion` holds the currently visible suggestion.
|
|
101
|
+
- `generationId` invalidates stale async model responses.
|
|
102
|
+
- `lastCtx` lets the custom editor accept/clear via current UI context.
|
|
103
|
+
|
|
104
|
+
### 2. Render suggestion
|
|
105
|
+
|
|
106
|
+
Default `ghost` mode renders inside the custom editor by modifying the editor render output. This is intentionally hacky and may need adjustment if Pi editor rendering changes.
|
|
107
|
+
|
|
108
|
+
Fallback `belowEditor` mode uses a widget below the editor:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
ctx.ui.setWidget(
|
|
112
|
+
"next-prompt-suggestion",
|
|
113
|
+
suggestion
|
|
114
|
+
? (_tui, theme) => ({
|
|
115
|
+
render: () => [theme.fg("dim", `→ ${suggestion}`)],
|
|
116
|
+
invalidate: () => {},
|
|
117
|
+
})
|
|
118
|
+
: undefined,
|
|
119
|
+
{ placement: "belowEditor" }
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Clear with:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
ctx.ui.setWidget("next-prompt-suggestion", undefined);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 3. Add custom editor wrapper
|
|
130
|
+
|
|
131
|
+
Subclass pi's `CustomEditor`:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
class SuggestionEditor extends CustomEditor {
|
|
135
|
+
handleInput(data: string): void {
|
|
136
|
+
if (this.getText().length === 0 && suggestion) {
|
|
137
|
+
if (isRightArrow(data)) {
|
|
138
|
+
this.setText(suggestion);
|
|
139
|
+
clearSuggestion();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isEnter(data)) {
|
|
144
|
+
this.setText(suggestion);
|
|
145
|
+
clearSuggestion();
|
|
146
|
+
super.handleInput(data);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (suggestion && isUserEditKey(data)) {
|
|
152
|
+
clearSuggestion();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
super.handleInput(data);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Important constraints:
|
|
161
|
+
|
|
162
|
+
- Do not use `registerShortcut("right")`; it would consume Right Arrow globally and break cursor movement.
|
|
163
|
+
- Only intercept Right Arrow/Enter when the editor is empty and a suggestion exists.
|
|
164
|
+
- Right Arrow fills the editor without submitting.
|
|
165
|
+
- Enter fills the editor and passes through to normal submit handling.
|
|
166
|
+
- Otherwise pass through to `super.handleInput(data)`.
|
|
167
|
+
|
|
168
|
+
### 4. Install editor on `session_start`
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
pi.on("session_start", (_event, ctx) => {
|
|
172
|
+
lastCtx = ctx;
|
|
173
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
|
|
174
|
+
new SuggestionEditor(tui, theme, keybindings)
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### 5. Generate suggestions on `agent_end`
|
|
180
|
+
|
|
181
|
+
On `agent_end`:
|
|
182
|
+
|
|
183
|
+
1. Clear existing suggestion.
|
|
184
|
+
2. Skip if no UI.
|
|
185
|
+
3. Skip if there are queued messages.
|
|
186
|
+
4. Skip if editor is non-empty.
|
|
187
|
+
5. Start async generation.
|
|
188
|
+
6. Ignore stale response if `generationId` changed.
|
|
189
|
+
7. Validate/sanitize output.
|
|
190
|
+
8. Render widget.
|
|
191
|
+
|
|
192
|
+
Pseudo-code:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
196
|
+
clearSuggestion();
|
|
197
|
+
|
|
198
|
+
if (!ctx.hasUI) return;
|
|
199
|
+
if (ctx.hasPendingMessages()) return;
|
|
200
|
+
if (ctx.ui.getEditorText().trim()) return;
|
|
201
|
+
if (!ctx.model) return;
|
|
202
|
+
|
|
203
|
+
const id = ++generationId;
|
|
204
|
+
const text = await generateSuggestion(event.messages, ctx);
|
|
205
|
+
if (id !== generationId) return;
|
|
206
|
+
|
|
207
|
+
const clean = sanitizeSuggestion(text);
|
|
208
|
+
if (!clean) return;
|
|
209
|
+
|
|
210
|
+
suggestion = clean;
|
|
211
|
+
renderSuggestion(ctx);
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### 6. Suggestion prompt
|
|
216
|
+
|
|
217
|
+
Use the strict prompt in `prompts/suggestion-system-prompt.md`, modeled after Claude Code's observed behavior:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
[SUGGESTION MODE: Suggest what the user might naturally type next into pi.]
|
|
221
|
+
|
|
222
|
+
First, look at the user's recent messages, original request, and the assistant's latest response.
|
|
223
|
+
Predict what the user would naturally type next, not what you think they should do.
|
|
224
|
+
|
|
225
|
+
The test: would the user think "I was just about to type that"?
|
|
226
|
+
|
|
227
|
+
Good suggestions:
|
|
228
|
+
- are 2-12 words
|
|
229
|
+
- match the user's style
|
|
230
|
+
- are specific
|
|
231
|
+
- continue an obvious workflow
|
|
232
|
+
|
|
233
|
+
Examples:
|
|
234
|
+
- User asked to fix a bug and tests were not run: run the tests
|
|
235
|
+
- Code was written and obvious manual check remains: try it out
|
|
236
|
+
- Assistant asks whether to continue: yes
|
|
237
|
+
- Task complete and changes are ready: commit this
|
|
238
|
+
|
|
239
|
+
Never suggest:
|
|
240
|
+
- thanks / looks good / evaluative replies
|
|
241
|
+
- questions
|
|
242
|
+
- Claude/pi voice like "let me" or "I'll"
|
|
243
|
+
- new ideas the user did not ask about
|
|
244
|
+
- multiple sentences
|
|
245
|
+
- unsafe or sensitive actions, including security incidents, credentials, harm, or private data
|
|
246
|
+
|
|
247
|
+
If the user explicitly said what they will ask next, suggest that exact next request.
|
|
248
|
+
If a file was created/edited and tests/checks were not run, the next step is clear: suggest running the relevant test/check.
|
|
249
|
+
Only reply with nothing when there is genuinely no plausible next user prompt.
|
|
250
|
+
Reply with only the suggestion text.
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### 7. Model call
|
|
254
|
+
|
|
255
|
+
Use `completeSimple()` from `@earendil-works/pi-ai`.
|
|
256
|
+
|
|
257
|
+
Inputs:
|
|
258
|
+
|
|
259
|
+
- Current model: `ctx.model`
|
|
260
|
+
- API key/headers from `ctx.modelRegistry.getApiKeyAndHeaders(ctx.model)`
|
|
261
|
+
- Short context derived from recent `event.messages`
|
|
262
|
+
- Small bounded max token limit; current implementation uses 256 so reasoning models have room to emit visible text
|
|
263
|
+
- Do not pass `temperature`; some Pi providers/models reject it
|
|
264
|
+
|
|
265
|
+
Model selection:
|
|
266
|
+
|
|
267
|
+
- Use the active Pi model by default.
|
|
268
|
+
- Optionally use config `model` as `provider/modelId` for a cheaper/faster suggestion model.
|
|
269
|
+
- If the configured model is missing or invalid, fall back to the active Pi model.
|
|
270
|
+
|
|
271
|
+
### 8. Sanitize output
|
|
272
|
+
|
|
273
|
+
Reject suggestions that are likely bad:
|
|
274
|
+
|
|
275
|
+
- empty
|
|
276
|
+
- more than `maxChars` characters, default 80
|
|
277
|
+
- more than 12 words
|
|
278
|
+
- fewer than 2 words unless the single word is an allowed command/confirmation like `yes`, `continue`, `commit`, or `/help`
|
|
279
|
+
- more than one sentence
|
|
280
|
+
- contains newline
|
|
281
|
+
- contains markdown formatting such as bullets or `**`
|
|
282
|
+
- starts with labels like `Suggestion:` or `User:`
|
|
283
|
+
- wraps meta text like `[no suggestion]` or `(silence)`
|
|
284
|
+
- is model meta-output like `no suggestion`, `nothing to suggest`, `silence`, or `stay silent`
|
|
285
|
+
- is provider/error text like `api error:`, `prompt is too long`, `request timed out`, or `invalid api key`
|
|
286
|
+
- starts with assistant voice:
|
|
287
|
+
- `let me`
|
|
288
|
+
- `I'll`
|
|
289
|
+
- `I can`
|
|
290
|
+
- `Here's`
|
|
291
|
+
- ends with `?`
|
|
292
|
+
- is gratitude/evaluation:
|
|
293
|
+
- `thanks`
|
|
294
|
+
- `thank you`
|
|
295
|
+
- `looks good`
|
|
296
|
+
- `great`
|
|
297
|
+
- `perfect`
|
|
298
|
+
|
|
299
|
+
Normalize:
|
|
300
|
+
|
|
301
|
+
- trim whitespace
|
|
302
|
+
- strip wrapping quotes
|
|
303
|
+
- strip trailing period if otherwise valid
|
|
304
|
+
|
|
305
|
+
### 9. Clear suggestion events
|
|
306
|
+
|
|
307
|
+
Clear suggestion on:
|
|
308
|
+
|
|
309
|
+
- `agent_start`
|
|
310
|
+
- `input`
|
|
311
|
+
- `session_shutdown`
|
|
312
|
+
- `session_start`
|
|
313
|
+
- user typing/editing in the custom editor
|
|
314
|
+
- accepting the suggestion
|
|
315
|
+
- `/reload` indirectly through session lifecycle
|
|
316
|
+
|
|
317
|
+
### 10. Error handling
|
|
318
|
+
|
|
319
|
+
If suggestion generation fails:
|
|
320
|
+
|
|
321
|
+
- Do not show an error by default.
|
|
322
|
+
- Optionally log/debug only.
|
|
323
|
+
- Never block agent completion or user input.
|
|
324
|
+
|
|
325
|
+
### 11. Configuration
|
|
326
|
+
|
|
327
|
+
Supported options:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
const config = {
|
|
331
|
+
enabled: true,
|
|
332
|
+
acceptTab: false,
|
|
333
|
+
display: "ghost",
|
|
334
|
+
maxChars: 80,
|
|
335
|
+
maxTokens: 256,
|
|
336
|
+
model: undefined,
|
|
337
|
+
};
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The current implementation loads extension-specific config from:
|
|
341
|
+
|
|
342
|
+
```text
|
|
343
|
+
~/.pi/agent/extensions/prompt-suggestions.json
|
|
344
|
+
.pi/prompt-suggestions.json
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Project config overrides global config:
|
|
348
|
+
|
|
349
|
+
```json
|
|
350
|
+
{
|
|
351
|
+
"enabled": true,
|
|
352
|
+
"acceptTab": false,
|
|
353
|
+
"display": "ghost",
|
|
354
|
+
"model": "openai/gpt-5-mini",
|
|
355
|
+
"maxTokens": 256,
|
|
356
|
+
"maxChars": 80
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Possible future additions:
|
|
361
|
+
|
|
362
|
+
- extension flags for one-off overrides
|
|
363
|
+
- `/next-suggestion on|off`
|
|
364
|
+
|
|
365
|
+
## Risks
|
|
366
|
+
|
|
367
|
+
1. **Right Arrow detection**
|
|
368
|
+
- Use pi-tui `matchesKey(data, "right")` rather than raw escape sequences.
|
|
369
|
+
|
|
370
|
+
2. **Editor compatibility**
|
|
371
|
+
- Extend `CustomEditor`, not base `Editor`, so app-level keybindings still work.
|
|
372
|
+
|
|
373
|
+
3. **Stale async result**
|
|
374
|
+
- Use `generationId` and clear on new input/agent start.
|
|
375
|
+
|
|
376
|
+
4. **Suggestion quality**
|
|
377
|
+
- Prompt must strongly prefer silence over weak suggestions.
|
|
378
|
+
- Sanitizer should reject questionable output.
|
|
379
|
+
|
|
380
|
+
5. **Latency/cost**
|
|
381
|
+
- Run after agent end, asynchronously.
|
|
382
|
+
- Use low max tokens.
|
|
383
|
+
- Consider cheaper model later.
|
|
384
|
+
|
|
385
|
+
## Acceptance Criteria
|
|
386
|
+
|
|
387
|
+
- After a successful obvious task, a short suggestion appears as ghost text, or below the editor when `display` is `belowEditor`.
|
|
388
|
+
- Pressing Right Arrow on an empty editor fills the suggestion.
|
|
389
|
+
- Pressing Enter on an empty editor submits the suggestion.
|
|
390
|
+
- Pressing Right Arrow or Enter with non-empty editor behaves normally.
|
|
391
|
+
- Typing clears the suggestion.
|
|
392
|
+
- Tab/autocomplete behavior is unchanged unless `acceptTab` is enabled.
|
|
393
|
+
- No suggestion appears for unclear next steps.
|
|
394
|
+
- Suggestion generation failure is silent and non-blocking.
|
|
395
|
+
|
|
396
|
+
## Future Improvements
|
|
397
|
+
|
|
398
|
+
1. Native inline ghost text if pi core adds editor suggestion primitives.
|
|
399
|
+
2. User command to enable/disable suggestions.
|
|
400
|
+
3. Heuristic suggestions without model call for common cases:
|
|
401
|
+
- tests not run
|
|
402
|
+
- git changes present
|
|
403
|
+
- assistant asked yes/no
|
|
404
|
+
4. Per-project style learning from prompt history.
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-prompt-suggestions",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Pi extension that suggests a natural next prompt after an agent response.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"prompt-suggestions"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/SteelDynamite/pi-prompt-suggestions#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/SteelDynamite/pi-prompt-suggestions/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/SteelDynamite/pi-prompt-suggestions.git"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"docs",
|
|
27
|
+
"prompts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"CHANGELOG.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
34
|
+
"validate": "npm run typecheck && npm test && npm pack --dry-run"
|
|
35
|
+
},
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./src/index.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"@earendil-works/pi-tui": "*"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@earendil-works/pi-ai": "^0.75.5",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^0.75.5",
|
|
52
|
+
"@earendil-works/pi-tui": "^0.75.5",
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"typescript": "^5.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[SUGGESTION MODE: Suggest what the user might naturally type next into pi.]
|
|
2
|
+
|
|
3
|
+
First, look at the user's recent messages, original request, and the assistant's latest response.
|
|
4
|
+
Predict what the user would naturally type next, not what you think they should do.
|
|
5
|
+
|
|
6
|
+
The test: would the user think "I was just about to type that"?
|
|
7
|
+
|
|
8
|
+
Good suggestions:
|
|
9
|
+
- are 2-12 words
|
|
10
|
+
- match the user's style
|
|
11
|
+
- are specific
|
|
12
|
+
- continue an obvious workflow
|
|
13
|
+
- are imperative user prompts like "run the tests" or "commit this"
|
|
14
|
+
- follow an explicit user-stated next request
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
- User asked to fix a bug and tests were not run: run the tests
|
|
18
|
+
- User asked to create or edit package.json with a test script and tests were not run: run the tests
|
|
19
|
+
- User said "count to 10 and then I will ask you to count to 20" and assistant counted to 10: count to 20
|
|
20
|
+
- Code was written and obvious manual check remains: try it out
|
|
21
|
+
- Assistant asks whether to continue: yes
|
|
22
|
+
- Task complete and changes are ready: commit this
|
|
23
|
+
|
|
24
|
+
Never suggest:
|
|
25
|
+
- thanks / looks good / evaluative replies
|
|
26
|
+
- questions
|
|
27
|
+
- new ideas the user did not ask about
|
|
28
|
+
- multiple sentences
|
|
29
|
+
- unsafe or sensitive actions, including security incidents, credentials, harm, or private data
|
|
30
|
+
|
|
31
|
+
If the user explicitly said what they will ask next, suggest that exact next request.
|
|
32
|
+
If a file was created/edited and tests/checks were not run, the next step is clear: suggest running the relevant test/check.
|
|
33
|
+
Only reply with nothing when there is genuinely no plausible next user prompt.
|
|
34
|
+
Reply with only the suggestion text.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
CustomEditor,
|
|
5
|
+
convertToLlm,
|
|
6
|
+
getAgentDir,
|
|
7
|
+
type AgentEndEvent,
|
|
8
|
+
type ExtensionAPI,
|
|
9
|
+
type ExtensionContext,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { completeSimple, type AssistantMessage, type Message, type Model } from "@earendil-works/pi-ai";
|
|
12
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
const WIDGET_KEY = "next-prompt-suggestion";
|
|
15
|
+
const DEFAULT_MAX_CHARS = 80;
|
|
16
|
+
const DEFAULT_MAX_TOKENS = 256;
|
|
17
|
+
const GLOBAL_CONFIG_RELATIVE_PATH = ["extensions", "prompt-suggestions.json"];
|
|
18
|
+
const PROJECT_CONFIG_RELATIVE_PATH = [".pi", "prompt-suggestions.json"];
|
|
19
|
+
const PROMPT_RELATIVE_PATH = ["prompts", "suggestion-system-prompt.md"];
|
|
20
|
+
const GHOST_CURSOR = "\x1b[7m \x1b[0m";
|
|
21
|
+
const GHOST_STYLE = "\x1b[2;90m";
|
|
22
|
+
const ANSI_RESET = "\x1b[0m";
|
|
23
|
+
const ALLOWED_SINGLE_WORD_SUGGESTIONS = new Set([
|
|
24
|
+
"yes",
|
|
25
|
+
"yeah",
|
|
26
|
+
"yep",
|
|
27
|
+
"yea",
|
|
28
|
+
"yup",
|
|
29
|
+
"sure",
|
|
30
|
+
"ok",
|
|
31
|
+
"okay",
|
|
32
|
+
"push",
|
|
33
|
+
"commit",
|
|
34
|
+
"deploy",
|
|
35
|
+
"stop",
|
|
36
|
+
"continue",
|
|
37
|
+
"check",
|
|
38
|
+
"exit",
|
|
39
|
+
"quit",
|
|
40
|
+
"no",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
type SuggestionDisplayMode = "ghost" | "belowEditor";
|
|
44
|
+
|
|
45
|
+
interface PromptSuggestionsConfig {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
acceptTab: boolean;
|
|
48
|
+
display: SuggestionDisplayMode;
|
|
49
|
+
maxChars: number;
|
|
50
|
+
maxTokens: number;
|
|
51
|
+
model?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type PromptSuggestionsConfigInput = Partial<PromptSuggestionsConfig>;
|
|
55
|
+
|
|
56
|
+
let suggestion: string | undefined;
|
|
57
|
+
let generationId = 0;
|
|
58
|
+
let lastCtx: ExtensionContext | undefined;
|
|
59
|
+
let currentConfig: PromptSuggestionsConfig = mergeConfigInputs();
|
|
60
|
+
let currentEditor: SuggestionEditor | undefined;
|
|
61
|
+
|
|
62
|
+
class SuggestionEditor extends CustomEditor {
|
|
63
|
+
requestRender(): void {
|
|
64
|
+
this.tui.requestRender(true);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
render(width: number): string[] {
|
|
68
|
+
const lines = super.render(width);
|
|
69
|
+
if (currentConfig.display !== "ghost" || !suggestion || this.getText().length > 0) return lines;
|
|
70
|
+
return renderGhostSuggestionLines(lines, width, suggestion);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
handleInput(data: string): void {
|
|
74
|
+
if (this.getText().length === 0 && suggestion) {
|
|
75
|
+
if (matchesKey(data, Key.right) || (currentConfig.acceptTab && matchesKey(data, Key.tab))) {
|
|
76
|
+
this.setText(suggestion);
|
|
77
|
+
clearSuggestion();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (matchesKey(data, Key.enter)) {
|
|
82
|
+
this.setText(suggestion);
|
|
83
|
+
clearSuggestion();
|
|
84
|
+
super.handleInput(data);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (suggestion && isUserEditKey(data)) {
|
|
90
|
+
clearSuggestion();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
super.handleInput(data);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default function promptSuggestions(pi: ExtensionAPI) {
|
|
98
|
+
pi.on("session_start", (_event, ctx) => {
|
|
99
|
+
lastCtx = ctx;
|
|
100
|
+
currentConfig = loadConfig(ctx.cwd, (message) => debug(ctx, message));
|
|
101
|
+
clearSuggestion(ctx);
|
|
102
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
103
|
+
currentEditor = new SuggestionEditor(tui, theme, keybindings);
|
|
104
|
+
return currentEditor;
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
109
|
+
lastCtx = ctx;
|
|
110
|
+
clearSuggestion(ctx);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.on("input", (_event, ctx) => {
|
|
114
|
+
lastCtx = ctx;
|
|
115
|
+
clearSuggestion(ctx);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
119
|
+
clearSuggestion(ctx);
|
|
120
|
+
ctx.ui.setEditorComponent(undefined);
|
|
121
|
+
currentEditor = undefined;
|
|
122
|
+
lastCtx = undefined;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
126
|
+
lastCtx = ctx;
|
|
127
|
+
clearSuggestion(ctx);
|
|
128
|
+
|
|
129
|
+
const config = loadConfig(ctx.cwd, (message) => debug(ctx, message));
|
|
130
|
+
if (!config.enabled) return debug(ctx, "skipped: disabled by config");
|
|
131
|
+
if (!ctx.hasUI) return;
|
|
132
|
+
if (ctx.hasPendingMessages()) return debug(ctx, "skipped: pending messages");
|
|
133
|
+
if (ctx.ui.getEditorText().trim().length > 0) return debug(ctx, "skipped: editor is not empty");
|
|
134
|
+
|
|
135
|
+
const model = resolveSuggestionModel(ctx, config.model);
|
|
136
|
+
if (!model) return debug(ctx, "skipped: no model selected");
|
|
137
|
+
|
|
138
|
+
const id = ++generationId;
|
|
139
|
+
debug(ctx, "generating...");
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const text = await generateSuggestion(event.messages, ctx, model, config);
|
|
143
|
+
debug(ctx, `raw: ${JSON.stringify(truncatePlain(text, 160))}`);
|
|
144
|
+
if (id !== generationId) return debug(ctx, "ignored: stale result");
|
|
145
|
+
if (ctx.hasPendingMessages()) return debug(ctx, "ignored: pending messages appeared");
|
|
146
|
+
if (ctx.ui.getEditorText().trim().length > 0) return debug(ctx, "ignored: editor became non-empty");
|
|
147
|
+
|
|
148
|
+
const clean = sanitizeSuggestion(text, config.maxChars);
|
|
149
|
+
if (!clean) return debug(ctx, `rejected: ${JSON.stringify(truncatePlain(text, 160))}`);
|
|
150
|
+
|
|
151
|
+
showSuggestion(clean, ctx);
|
|
152
|
+
debug(ctx, `shown: ${clean}`);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
debug(ctx, `error: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
+
// Suggestion generation is best-effort and must never interrupt normal use.
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function clearSuggestion(ctx = lastCtx): void {
|
|
161
|
+
const hadSuggestion = suggestion !== undefined;
|
|
162
|
+
generationId++;
|
|
163
|
+
suggestion = undefined;
|
|
164
|
+
ctx?.ui.setWidget(WIDGET_KEY, undefined);
|
|
165
|
+
if (hadSuggestion && currentConfig.display === "ghost") currentEditor?.requestRender();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function showSuggestion(text: string, ctx = lastCtx): void {
|
|
169
|
+
clearSuggestion(ctx);
|
|
170
|
+
suggestion = text;
|
|
171
|
+
renderSuggestion(ctx);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderSuggestion(ctx = lastCtx): void {
|
|
175
|
+
if (!ctx || !suggestion) return;
|
|
176
|
+
currentConfig = loadConfig(ctx.cwd, (message) => debug(ctx, message));
|
|
177
|
+
if (currentConfig.display === "ghost") {
|
|
178
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
179
|
+
currentEditor?.requestRender();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
ctx.ui.setWidget(
|
|
183
|
+
WIDGET_KEY,
|
|
184
|
+
(_tui, theme) => ({
|
|
185
|
+
render: (width: number) => [truncateToWidth(theme.fg("dim", `→ ${suggestion}`), width)],
|
|
186
|
+
invalidate: () => {},
|
|
187
|
+
}),
|
|
188
|
+
{ placement: "belowEditor" },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderGhostSuggestionLines(lines: string[], width: number, text: string): string[] {
|
|
193
|
+
const contentLineIndex = lines.length >= 3 ? 1 : lines.findIndex((line) => line.includes(GHOST_CURSOR));
|
|
194
|
+
if (contentLineIndex === -1) return lines;
|
|
195
|
+
|
|
196
|
+
const available = Math.max(0, width - 1);
|
|
197
|
+
const ghost = `${GHOST_STYLE}${truncateToWidth(text, available)}${ANSI_RESET}`;
|
|
198
|
+
const rendered = GHOST_CURSOR + ghost;
|
|
199
|
+
return lines.map((line, index) =>
|
|
200
|
+
index === contentLineIndex ? rendered + " ".repeat(Math.max(0, width - visibleWidth(rendered))) : line,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function generateSuggestion(
|
|
205
|
+
messages: AgentEndEvent["messages"],
|
|
206
|
+
ctx: ExtensionContext,
|
|
207
|
+
model: Model<any>,
|
|
208
|
+
config: PromptSuggestionsConfig,
|
|
209
|
+
): Promise<string> {
|
|
210
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
211
|
+
if (!auth.ok) {
|
|
212
|
+
debug(ctx, `auth unavailable: ${"error" in auth ? auth.error : "unknown error"}`);
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const llmMessages = convertToLlm(messages);
|
|
217
|
+
const context = buildSuggestionContext(llmMessages);
|
|
218
|
+
debug(ctx, `context: ${JSON.stringify(truncatePlain(context, 240))}`);
|
|
219
|
+
const options = {
|
|
220
|
+
apiKey: auth.apiKey,
|
|
221
|
+
headers: auth.headers,
|
|
222
|
+
maxTokens: config.maxTokens,
|
|
223
|
+
reasoning: model.reasoning ? ("minimal" as const) : undefined,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const response = await completeSimple(
|
|
227
|
+
model,
|
|
228
|
+
{
|
|
229
|
+
systemPrompt: loadSuggestionSystemPrompt(ctx.cwd, (message) => debug(ctx, message)),
|
|
230
|
+
messages: [
|
|
231
|
+
{
|
|
232
|
+
role: "user",
|
|
233
|
+
content: context,
|
|
234
|
+
timestamp: Date.now(),
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
options,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
debug(
|
|
242
|
+
ctx,
|
|
243
|
+
`response: ${response.stopReason}; ${response.content.map((part) => part.type).join(",")}; ${response.errorMessage ?? ""}`,
|
|
244
|
+
);
|
|
245
|
+
if (response.diagnostics?.length) {
|
|
246
|
+
debug(ctx, `diagnostics: ${JSON.stringify(response.diagnostics).slice(0, 500)}`);
|
|
247
|
+
}
|
|
248
|
+
return extractAssistantText(response);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function loadSuggestionSystemPrompt(cwd: string, onWarning?: (message: string) => void): string {
|
|
252
|
+
const packagePromptPath = join(resolvePackageRoot(cwd), ...PROMPT_RELATIVE_PATH);
|
|
253
|
+
try {
|
|
254
|
+
return readFileSync(packagePromptPath, "utf-8").trim();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
onWarning?.(`prompt load failed: ${packagePromptPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
257
|
+
return FALLBACK_SUGGESTION_SYSTEM_PROMPT;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function resolvePackageRoot(cwd: string): string {
|
|
262
|
+
let dir = import.meta.dirname;
|
|
263
|
+
while (dir !== join(dir, "..")) {
|
|
264
|
+
if (existsSync(join(dir, "package.json")) && existsSync(join(dir, "src", "index.ts"))) return dir;
|
|
265
|
+
dir = join(dir, "..");
|
|
266
|
+
}
|
|
267
|
+
return cwd;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const FALLBACK_SUGGESTION_SYSTEM_PROMPT = `[SUGGESTION MODE: Suggest what the user might naturally type next into pi.]\n\nReply with only a short natural next prompt, or nothing if unclear.`;
|
|
271
|
+
|
|
272
|
+
function buildSuggestionContext(messages: Message[]): string {
|
|
273
|
+
const recent = messages.slice(-8).map(formatMessageForSuggestion).filter(Boolean);
|
|
274
|
+
return `Recent conversation from the just-finished agent turn:\n\n${recent.join("\n\n")}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function formatMessageForSuggestion(message: Message): string {
|
|
278
|
+
const role = getMessageRole(message);
|
|
279
|
+
const text = extractMessageText(message).trim();
|
|
280
|
+
if (!text) return `${role}: [no text]`;
|
|
281
|
+
return `${role}: ${truncatePlain(text, 2_000)}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getMessageRole(message: unknown): string {
|
|
285
|
+
if (isRecord(message) && typeof message.role === "string") return message.role;
|
|
286
|
+
if (isRecord(message) && typeof message.type === "string") return message.type;
|
|
287
|
+
return "message";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function extractMessageText(message: unknown): string {
|
|
291
|
+
if (!isRecord(message)) return "";
|
|
292
|
+
const { content } = message;
|
|
293
|
+
if (typeof content === "string") return content;
|
|
294
|
+
if (!Array.isArray(content)) return JSON.stringify(message);
|
|
295
|
+
return content
|
|
296
|
+
.map((part) => {
|
|
297
|
+
if (!isRecord(part) || typeof part.type !== "string") return "";
|
|
298
|
+
if (part.type === "text" && typeof part.text === "string") return part.text;
|
|
299
|
+
if (part.type === "thinking") return "";
|
|
300
|
+
if (part.type === "toolCall" && typeof part.name === "string") return `[tool call: ${part.name}]`;
|
|
301
|
+
if (part.type === "image") return "[image]";
|
|
302
|
+
return "";
|
|
303
|
+
})
|
|
304
|
+
.join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function extractAssistantText(message: AssistantMessage): string {
|
|
308
|
+
return message.content
|
|
309
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
310
|
+
.join("")
|
|
311
|
+
.trim();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
315
|
+
return typeof value === "object" && value !== null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function sanitizeSuggestion(text: string, maxChars = DEFAULT_MAX_CHARS): string | undefined {
|
|
319
|
+
let clean = text.trim();
|
|
320
|
+
if (!clean) return undefined;
|
|
321
|
+
if (clean.includes("\n")) return undefined;
|
|
322
|
+
|
|
323
|
+
clean = clean.replace(/^```(?:\w+)?\s*/, "").replace(/\s*```$/, "").trim();
|
|
324
|
+
clean = clean.replace(/^['"“”‘’]+|['"“”‘’]+$/g, "").trim();
|
|
325
|
+
clean = clean.replace(/\.$/, "").trim();
|
|
326
|
+
|
|
327
|
+
if (!clean) return undefined;
|
|
328
|
+
if (clean.length > maxChars) return undefined;
|
|
329
|
+
if (clean.endsWith("?")) return undefined;
|
|
330
|
+
if (/[.!?].+\S/.test(clean)) return undefined;
|
|
331
|
+
if (/[\n*]|\*\*/.test(clean)) return undefined;
|
|
332
|
+
if (/^\w+:\s/.test(clean)) return undefined;
|
|
333
|
+
if (/^\(.*\)$|^\[.*\]$/.test(clean)) return undefined;
|
|
334
|
+
|
|
335
|
+
const lower = clean.toLowerCase();
|
|
336
|
+
const wordCount = clean.split(/\s+/).length;
|
|
337
|
+
if (lower === "done") return undefined;
|
|
338
|
+
if (isMetaSuggestion(lower)) return undefined;
|
|
339
|
+
if (isErrorSuggestion(lower)) return undefined;
|
|
340
|
+
if (wordCount > 12) return undefined;
|
|
341
|
+
if (wordCount < 2 && !isAllowedSingleWordSuggestion(lower, clean)) return undefined;
|
|
342
|
+
if (/^(let me|i'll|i've|i'm|i can|i would|i think|i notice|here's|here is|here are|that's|this is|this will|you can|you should|you could|sure,|of course|certainly)\b/i.test(clean)) return undefined;
|
|
343
|
+
if (/thanks|thank you|looks good|sounds good|that works|that worked|that's all|nice|great|perfect|makes sense|awesome|excellent/i.test(clean)) return undefined;
|
|
344
|
+
|
|
345
|
+
return clean;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isMetaSuggestion(lower: string): boolean {
|
|
349
|
+
return (
|
|
350
|
+
lower === "nothing found" ||
|
|
351
|
+
lower.startsWith("nothing to suggest") ||
|
|
352
|
+
lower.startsWith("no suggestion") ||
|
|
353
|
+
/\bsilence is\b|\bstay(s|ing)? silent\b/.test(lower) ||
|
|
354
|
+
/^\W*silence\W*$/.test(lower)
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isErrorSuggestion(lower: string): boolean {
|
|
359
|
+
return (
|
|
360
|
+
lower.startsWith("api error:") ||
|
|
361
|
+
lower.startsWith("prompt is too long") ||
|
|
362
|
+
lower.startsWith("request timed out") ||
|
|
363
|
+
lower.startsWith("invalid api key") ||
|
|
364
|
+
lower.startsWith("image was too large")
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isAllowedSingleWordSuggestion(lower: string, clean: string): boolean {
|
|
369
|
+
if (clean.startsWith("/")) return true;
|
|
370
|
+
return ALLOWED_SINGLE_WORD_SUGGESTIONS.has(lower);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isUserEditKey(data: string): boolean {
|
|
374
|
+
if (data.length === 1 && data.charCodeAt(0) >= 32) return true;
|
|
375
|
+
return (
|
|
376
|
+
matchesKey(data, Key.backspace) ||
|
|
377
|
+
matchesKey(data, Key.delete) ||
|
|
378
|
+
matchesKey(data, Key.enter) ||
|
|
379
|
+
matchesKey(data, Key.tab)
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function truncatePlain(text: string, max: number): string {
|
|
384
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function loadConfig(cwd: string, onWarning?: (message: string) => void): PromptSuggestionsConfig {
|
|
388
|
+
const globalPath = join(getAgentDir(), ...GLOBAL_CONFIG_RELATIVE_PATH);
|
|
389
|
+
const projectPath = join(cwd, ...PROJECT_CONFIG_RELATIVE_PATH);
|
|
390
|
+
return mergeConfigInputs(
|
|
391
|
+
readConfigFile(globalPath, onWarning),
|
|
392
|
+
readConfigFile(projectPath, onWarning),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function readConfigFile(path: string, onWarning?: (message: string) => void): PromptSuggestionsConfigInput {
|
|
397
|
+
if (!existsSync(path)) return {};
|
|
398
|
+
try {
|
|
399
|
+
return parseConfigInput(JSON.parse(readFileSync(path, "utf-8")), path, onWarning);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
onWarning?.(`config ignored: ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
402
|
+
return {};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseConfigInput(
|
|
407
|
+
value: unknown,
|
|
408
|
+
path = "config",
|
|
409
|
+
onWarning?: (message: string) => void,
|
|
410
|
+
): PromptSuggestionsConfigInput {
|
|
411
|
+
if (!isRecord(value)) {
|
|
412
|
+
onWarning?.(`config ignored: ${path}: expected object`);
|
|
413
|
+
return {};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const config: PromptSuggestionsConfigInput = {};
|
|
417
|
+
if ("enabled" in value) {
|
|
418
|
+
if (typeof value.enabled === "boolean") config.enabled = value.enabled;
|
|
419
|
+
else onWarning?.(`config ignored: ${path}: enabled must be boolean`);
|
|
420
|
+
}
|
|
421
|
+
if ("model" in value) {
|
|
422
|
+
if (typeof value.model === "string" && value.model.trim()) config.model = value.model.trim();
|
|
423
|
+
else onWarning?.(`config ignored: ${path}: model must be non-empty string`);
|
|
424
|
+
}
|
|
425
|
+
if ("acceptTab" in value) {
|
|
426
|
+
if (typeof value.acceptTab === "boolean") config.acceptTab = value.acceptTab;
|
|
427
|
+
else onWarning?.(`config ignored: ${path}: acceptTab must be boolean`);
|
|
428
|
+
}
|
|
429
|
+
if ("display" in value) {
|
|
430
|
+
if (value.display === "ghost" || value.display === "belowEditor") config.display = value.display;
|
|
431
|
+
else onWarning?.(`config ignored: ${path}: display must be \"ghost\" or \"belowEditor\"`);
|
|
432
|
+
}
|
|
433
|
+
if ("maxChars" in value) {
|
|
434
|
+
if (isPositiveInteger(value.maxChars)) config.maxChars = value.maxChars;
|
|
435
|
+
else onWarning?.(`config ignored: ${path}: maxChars must be positive integer`);
|
|
436
|
+
}
|
|
437
|
+
if ("maxTokens" in value) {
|
|
438
|
+
if (isPositiveInteger(value.maxTokens)) config.maxTokens = value.maxTokens;
|
|
439
|
+
else onWarning?.(`config ignored: ${path}: maxTokens must be positive integer`);
|
|
440
|
+
}
|
|
441
|
+
return config;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function mergeConfigInputs(...configs: PromptSuggestionsConfigInput[]): PromptSuggestionsConfig {
|
|
445
|
+
return {
|
|
446
|
+
enabled: true,
|
|
447
|
+
acceptTab: false,
|
|
448
|
+
display: "ghost",
|
|
449
|
+
maxChars: DEFAULT_MAX_CHARS,
|
|
450
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
451
|
+
...Object.assign({}, ...configs),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function resolveSuggestionModel(ctx: ExtensionContext, configuredModel: string | undefined): Model<any> | undefined {
|
|
456
|
+
if (!configuredModel) return ctx.model;
|
|
457
|
+
const parsed = parseModelSpec(configuredModel);
|
|
458
|
+
if (!parsed) {
|
|
459
|
+
debug(ctx, `configured model ignored: expected provider/model, got ${configuredModel}`);
|
|
460
|
+
return ctx.model;
|
|
461
|
+
}
|
|
462
|
+
const model = ctx.modelRegistry.find(parsed.provider, parsed.model);
|
|
463
|
+
if (!model) {
|
|
464
|
+
debug(ctx, `configured model not found: ${configuredModel}`);
|
|
465
|
+
return ctx.model;
|
|
466
|
+
}
|
|
467
|
+
return model;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function parseModelSpec(spec: string): { provider: string; model: string } | undefined {
|
|
471
|
+
const slash = spec.indexOf("/");
|
|
472
|
+
if (slash <= 0 || slash === spec.length - 1) return undefined;
|
|
473
|
+
return { provider: spec.slice(0, slash), model: spec.slice(slash + 1) };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
477
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function debug(ctx: ExtensionContext, message: string): void {
|
|
481
|
+
if (process.env.PI_PROMPT_SUGGESTIONS_DEBUG !== "1") return;
|
|
482
|
+
ctx.ui.setStatus("next-suggestion", `suggestion: ${message}`);
|
|
483
|
+
ctx.ui.notify(`next-suggestion: ${message}`, "info");
|
|
484
|
+
try {
|
|
485
|
+
appendFileSync(join(ctx.cwd, "next-suggestion-debug.log"), `${new Date().toISOString()} ${message}\n`);
|
|
486
|
+
} catch {
|
|
487
|
+
// Debug logging must not affect the extension.
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export const __test__ = {
|
|
492
|
+
buildSuggestionContext,
|
|
493
|
+
extractAssistantText,
|
|
494
|
+
extractMessageText,
|
|
495
|
+
formatMessageForSuggestion,
|
|
496
|
+
getMessageRole,
|
|
497
|
+
loadSuggestionSystemPrompt,
|
|
498
|
+
mergeConfigInputs,
|
|
499
|
+
parseConfigInput,
|
|
500
|
+
parseModelSpec,
|
|
501
|
+
renderGhostSuggestionLines,
|
|
502
|
+
sanitizeSuggestion,
|
|
503
|
+
truncatePlain,
|
|
504
|
+
};
|