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 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
+ };