pi-ghost-text 0.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/LICENSE +21 -0
- package/README.md +121 -0
- package/package.json +45 -0
- package/src/client.ts +59 -0
- package/src/config.ts +114 -0
- package/src/context.ts +75 -0
- package/src/debug.ts +51 -0
- package/src/editor.ts +385 -0
- package/src/index.ts +164 -0
- package/src/mode.ts +19 -0
- package/src/model-selection.ts +45 -0
- package/src/normalize.ts +132 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Vaughan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# pi-ghost-text
|
|
2
|
+
|
|
3
|
+
Ghost-text prompt suggestions for the [pi coding agent](https://github.com/earendil-works/pi) — like Claude Code's inline autocomplete for the prompt box.
|
|
4
|
+
|
|
5
|
+
As you type (or after the agent finishes and the input is empty), a dimmed prediction of the likely next prompt appears after the cursor. Accept it, cycle alternatives, or keep typing.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- **Interactive TUI mode.** Suggestions are an editor feature and only appear in the interactive terminal (`pi` with no mode flag). They do not run in `-p`/`--print`, `--mode json`, or `--mode rpc`.
|
|
10
|
+
- **A model with a configured API key.** With the default `"model": "auto"`, the extension scans the available models for a fast/cheap one, in priority order: `haiku`, then `gpt-*mini`, then `flash`, then `nano`. If none match — or if an explicit `provider/model-id` isn't found — it falls back to the active session model. Set a specific model with `/suggest-model`.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pi install /path/to/pi-ghost-text
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Try it without installing:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi -e /path/to/pi-ghost-text/src/index.ts
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Remove it:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi remove /path/to/pi-ghost-text
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Using it
|
|
31
|
+
|
|
32
|
+
| Key | Action |
|
|
33
|
+
|-----|--------|
|
|
34
|
+
| `Tab` | Cycle to the next candidate; accept when it's the last (or only) one |
|
|
35
|
+
| `→` (right arrow, cursor at end) | Accept the current suggestion |
|
|
36
|
+
| `Esc` | Dismiss the ghost, keeping what you've typed |
|
|
37
|
+
| Move the cursor away from the end | Dismiss |
|
|
38
|
+
| Keep typing | Shrink the ghost while it still matches, otherwise regenerate |
|
|
39
|
+
|
|
40
|
+
`Tab` only applies to the ghost when the file/command autocomplete dropdown is **not** open. Accepting inserts the suggestion into the editor for you to edit; press `Enter` to send it.
|
|
41
|
+
|
|
42
|
+
When `candidates` is `1` (the default), `Tab` simply accepts. Set `candidates` to `2` or `3` to fetch alternatives and cycle them.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | What it does |
|
|
47
|
+
|---------|--------------|
|
|
48
|
+
| `/suggest` | Choose mode: `both`, `while-typing`, `after-turn`, or `off` |
|
|
49
|
+
| `/suggest-model` | Pick the suggestion model (or `auto` to prefer a fast, cheap one) |
|
|
50
|
+
| `/suggest-context` | Set the context window (messages × chars per message) |
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
Stored as JSON, resolved `defaults ← global ← project`:
|
|
55
|
+
|
|
56
|
+
- global: `~/.pi/agent/prompt-suggestions.json`
|
|
57
|
+
- project: `.pi/prompt-suggestions.json`
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"model": "auto",
|
|
62
|
+
"mode": "both",
|
|
63
|
+
"candidates": 1,
|
|
64
|
+
"streaming": true,
|
|
65
|
+
"contextMessages": 8,
|
|
66
|
+
"contextChars": 600,
|
|
67
|
+
"maxPerTurn": 0,
|
|
68
|
+
"debug": false
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Key | Default | Description |
|
|
73
|
+
|-----|---------|-------------|
|
|
74
|
+
| `model` | `"auto"` | `"auto"` (prefer a fast, cheap model) or an explicit `provider/model-id` |
|
|
75
|
+
| `mode` | `"both"` | `"both"`, `"while-typing"`, `"after-turn"`, or `"off"` |
|
|
76
|
+
| `candidates` | `1` | Alternatives to fetch (1–3); `Tab` cycles them |
|
|
77
|
+
| `streaming` | `true` | Stream the single suggestion token-by-token (ignored when `candidates > 1`) |
|
|
78
|
+
| `contextMessages` | `8` | Recent messages included in the suggestion prompt (`0` = none) |
|
|
79
|
+
| `contextChars` | `600` | Chars kept per message |
|
|
80
|
+
| `maxPerTurn` | `0` | Max suggestions shown per agent turn (`0` = unlimited) |
|
|
81
|
+
| `debug` | `false` | Append diagnostic lines to `~/.pi/agent/prompt-suggestions.log` |
|
|
82
|
+
|
|
83
|
+
`mode`, `model`, `contextMessages`, and `contextChars` are settable from the commands above; `candidates`, `streaming`, `maxPerTurn`, and `debug` are JSON-only. Commands write to the **global** scope, so a project override in `.pi/prompt-suggestions.json` takes precedence over a command-set value.
|
|
84
|
+
|
|
85
|
+
## Privacy
|
|
86
|
+
|
|
87
|
+
When enabled, the suggestion model receives only:
|
|
88
|
+
|
|
89
|
+
- the text you have typed so far, and
|
|
90
|
+
- the last `contextMessages` user/assistant messages, each trimmed to `contextChars` characters.
|
|
91
|
+
|
|
92
|
+
It is **never** sent:
|
|
93
|
+
|
|
94
|
+
- the full conversation transcript,
|
|
95
|
+
- tool outputs,
|
|
96
|
+
- file contents,
|
|
97
|
+
- project or session metadata.
|
|
98
|
+
|
|
99
|
+
Suggestions are advisory and best-effort; failures are never surfaced in the UI. With `"debug": true` they are logged to `~/.pi/agent/prompt-suggestions.log` with a stable category (`timeout` / `error` / `no-suggestion`).
|
|
100
|
+
|
|
101
|
+
## Troubleshooting
|
|
102
|
+
|
|
103
|
+
If no suggestion appears:
|
|
104
|
+
|
|
105
|
+
1. Confirm the mode is not `off` — run `/suggest`.
|
|
106
|
+
2. Confirm the suggestion model has an API key configured (or, for `auto`, that one of the fast/cheap models does). `/suggest-model` shows what's available. An explicit `model` that isn't found silently falls back to the active session model, so check the spelling as `provider/model-id`.
|
|
107
|
+
3. **While-typing suggestions** need ≥4 chars of plain text (not starting with `/` or `!`, not an `@file` completion in progress), a ~700ms pause, and the agent must be idle (not mid-response).
|
|
108
|
+
4. **After-turn suggestions** only appear when the editor is empty right after the agent settles.
|
|
109
|
+
5. If `maxPerTurn` is set, you may have hit the per-turn cap.
|
|
110
|
+
6. Set `"debug": true` and check `~/.pi/agent/prompt-suggestions.log` for `[prompt-suggestions]` lines — they state the reason (`timeout`, `error`, `no-suggestion`).
|
|
111
|
+
7. If the ghost flakes with streaming enabled, set `"streaming": false` to force the non-streaming path.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npm test # node --test, no dependencies (requires Node ≥ 23.6)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Prior art
|
|
120
|
+
|
|
121
|
+
Inspired by [supi-prompt-suggestions](https://pi.dev/packages/@mrclrchtr/supi-prompt-suggestions?name=supi). This adds inline while-typing autocomplete (prefix-anchored, shrink-on-type), multi-candidate cycling, streaming ghost text, scoped global/project config, and explicit modes on top of the empty-box next-prompt suggestion.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-ghost-text",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ghost-text prompt suggestions (inline autocomplete + next-prompt) for the pi coding agent",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Chris Vaughan",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=23.6"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/jcv/pi-ghost-text.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/jcv/pi-ghost-text#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/jcv/pi-ghost-text/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"pi-package",
|
|
21
|
+
"pi",
|
|
22
|
+
"pi-coding-agent",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"prompt-suggestions",
|
|
25
|
+
"prompt",
|
|
26
|
+
"suggestions",
|
|
27
|
+
"ghost-text",
|
|
28
|
+
"autocomplete"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": ["./src/index.ts"],
|
|
35
|
+
"image": "https://raw.githubusercontent.com/jcv/pi-ghost-text/main/assets/social-preview.png"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": "*",
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestion model client — prompt and completion options, plus response
|
|
3
|
+
* classification.
|
|
4
|
+
*
|
|
5
|
+
* Only `import type` from pi packages, so `classifyResponse` is testable
|
|
6
|
+
* with plain Node.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
12
|
+
|
|
13
|
+
export const GENERATION_TIMEOUT_MS = 8_000;
|
|
14
|
+
export const MAX_TOKENS = 150;
|
|
15
|
+
export const TEMPERATURE = 0.3;
|
|
16
|
+
|
|
17
|
+
export const SYSTEM_PROMPT = `You complete prompts in an AI coding assistant's input box, like ghost-text autocomplete.
|
|
18
|
+
|
|
19
|
+
Given the recent conversation and the text the user has typed so far, predict the full prompt the user is most likely to submit.
|
|
20
|
+
|
|
21
|
+
Rules:
|
|
22
|
+
- Output ONLY the predicted prompt text. No quotes, no explanation, no markdown.
|
|
23
|
+
- If partial input is provided, your output MUST begin with that exact text, unchanged.
|
|
24
|
+
- Keep it short: one line, at most two sentences.
|
|
25
|
+
- Be specific to the conversation. Prefer concrete next steps (run the tests, commit, fix the file just discussed) over generic requests.
|
|
26
|
+
- If there is no confident prediction, output exactly: NONE`;
|
|
27
|
+
|
|
28
|
+
export type ResponseClass =
|
|
29
|
+
| { ok: true; text: string }
|
|
30
|
+
| { ok: false; reason: "error" | "no-content" | "no-text"; message?: string };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Classify a completion response into usable suggestion text or a structured
|
|
34
|
+
* failure reason. Extracts only visible text content.
|
|
35
|
+
*/
|
|
36
|
+
export function classifyResponse(response: AssistantMessage): ResponseClass {
|
|
37
|
+
if (response.stopReason === "error") {
|
|
38
|
+
return { ok: false, reason: "error", message: response.errorMessage ?? response.stopReason };
|
|
39
|
+
}
|
|
40
|
+
if (!response.content || response.content.length === 0) {
|
|
41
|
+
return { ok: false, reason: "no-content" };
|
|
42
|
+
}
|
|
43
|
+
const text = response.content
|
|
44
|
+
.filter((c) => c.type === "text")
|
|
45
|
+
.map((c) => c.text ?? "")
|
|
46
|
+
.join("");
|
|
47
|
+
if (!text.trim()) {
|
|
48
|
+
return { ok: false, reason: "no-text" };
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, text };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** System prompt for one suggestion, or for several delimited alternatives. */
|
|
54
|
+
export function systemPromptFor(candidates: number): string {
|
|
55
|
+
if (candidates <= 1) return SYSTEM_PROMPT;
|
|
56
|
+
return `${SYSTEM_PROMPT}
|
|
57
|
+
|
|
58
|
+
Additional instruction: provide ${candidates} clearly DIFFERENT alternatives. Output each alternative with no numbering or bullets, separated by a line containing only "---". Each alternative must independently satisfy all the rules above.`;
|
|
59
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped config for prompt suggestions.
|
|
3
|
+
*
|
|
4
|
+
* Resolved as `defaults <- global <- project`:
|
|
5
|
+
* - global: ~/.pi/agent/prompt-suggestions.json
|
|
6
|
+
* - project: .pi/prompt-suggestions.json
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { isMode, type SuggestMode } from "./mode.ts";
|
|
16
|
+
|
|
17
|
+
export type { SuggestMode } from "./mode.ts";
|
|
18
|
+
|
|
19
|
+
export interface Config {
|
|
20
|
+
/** "auto" = auto-pick a fast model, or an explicit "provider/model-id". */
|
|
21
|
+
model: string;
|
|
22
|
+
/** Which suggestion behaviors are active. */
|
|
23
|
+
mode: SuggestMode;
|
|
24
|
+
/** Number of alternative suggestions to fetch (1..3). */
|
|
25
|
+
candidates: number;
|
|
26
|
+
/** Stream the single suggestion token-by-token (only when candidates === 1). */
|
|
27
|
+
streaming: boolean;
|
|
28
|
+
/** Number of recent messages included in the suggestion context. */
|
|
29
|
+
contextMessages: number;
|
|
30
|
+
/** Chars kept per message in the suggestion context. */
|
|
31
|
+
contextChars: number;
|
|
32
|
+
/** Max suggestions shown per agent turn (0 = unlimited). */
|
|
33
|
+
maxPerTurn: number;
|
|
34
|
+
/** Append debug lines to ~/.pi/agent/prompt-suggestions.log. */
|
|
35
|
+
debug: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_CONFIG: Config = {
|
|
39
|
+
model: "auto",
|
|
40
|
+
mode: "both",
|
|
41
|
+
candidates: 1,
|
|
42
|
+
streaming: true,
|
|
43
|
+
contextMessages: 8,
|
|
44
|
+
contextChars: 600,
|
|
45
|
+
maxPerTurn: 0,
|
|
46
|
+
debug: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function clampInt(value: unknown, min: number, max: number): number {
|
|
50
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return min;
|
|
51
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function globalConfigPath(): string {
|
|
55
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME, "agent", "prompt-suggestions.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function projectConfigPath(cwd: string): string {
|
|
59
|
+
return path.join(cwd, CONFIG_DIR_NAME, "prompt-suggestions.json");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readJsonFile(filePath: string): Record<string, unknown> | null {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
65
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
66
|
+
return parsed as Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Missing or invalid config is not an error.
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readConfig(cwd: string): Config {
|
|
75
|
+
const merged: Config = { ...DEFAULT_CONFIG };
|
|
76
|
+
let modeSeen = false;
|
|
77
|
+
let legacyEnabled: boolean | undefined;
|
|
78
|
+
|
|
79
|
+
for (const filePath of [globalConfigPath(), projectConfigPath(cwd)]) {
|
|
80
|
+
const raw = readJsonFile(filePath);
|
|
81
|
+
if (!raw) continue;
|
|
82
|
+
if (typeof raw.model === "string" && raw.model) merged.model = raw.model;
|
|
83
|
+
if (typeof raw.mode === "string" && isMode(raw.mode)) {
|
|
84
|
+
merged.mode = raw.mode;
|
|
85
|
+
modeSeen = true;
|
|
86
|
+
}
|
|
87
|
+
if (typeof raw.candidates !== "undefined") merged.candidates = clampInt(raw.candidates, 1, 3);
|
|
88
|
+
if (typeof raw.streaming === "boolean") merged.streaming = raw.streaming;
|
|
89
|
+
if (typeof raw.contextMessages !== "undefined") {
|
|
90
|
+
merged.contextMessages = clampInt(raw.contextMessages, 0, 50);
|
|
91
|
+
}
|
|
92
|
+
if (typeof raw.contextChars !== "undefined") {
|
|
93
|
+
merged.contextChars = clampInt(raw.contextChars, 0, 10_000);
|
|
94
|
+
}
|
|
95
|
+
if (typeof raw.maxPerTurn !== "undefined") {
|
|
96
|
+
merged.maxPerTurn = clampInt(raw.maxPerTurn, 0, 1_000);
|
|
97
|
+
}
|
|
98
|
+
if (typeof raw.debug === "boolean") merged.debug = raw.debug;
|
|
99
|
+
if (typeof raw.enabled === "boolean") legacyEnabled = raw.enabled;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Legacy migration: before `mode` existed, `enabled: false` meant off.
|
|
103
|
+
if (!modeSeen) merged.mode = legacyEnabled === false ? "off" : "both";
|
|
104
|
+
return merged;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeGlobalConfig(partial: Partial<Config>): void {
|
|
108
|
+
const filePath = globalConfigPath();
|
|
109
|
+
const existing = readJsonFile(filePath) ?? {};
|
|
110
|
+
delete existing.enabled; // superseded by `mode`
|
|
111
|
+
const next = { ...existing, ...partial };
|
|
112
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
113
|
+
fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
114
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation context for suggestion prompts.
|
|
3
|
+
*
|
|
4
|
+
* Only `import type` from pi packages, so `digestMessages` is unit-testable
|
|
5
|
+
* with plain Node.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { UserMessage } from "@earendil-works/pi-ai";
|
|
12
|
+
|
|
13
|
+
export interface ContextWindow {
|
|
14
|
+
messages: number;
|
|
15
|
+
chars: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function extractTextContent(
|
|
19
|
+
content: string | Array<{ type: string; text?: string }> | undefined,
|
|
20
|
+
): string {
|
|
21
|
+
if (typeof content === "string") return content;
|
|
22
|
+
if (!Array.isArray(content)) return "";
|
|
23
|
+
return content
|
|
24
|
+
.filter((c) => c.type === "text")
|
|
25
|
+
.map((c) => c.text ?? "")
|
|
26
|
+
.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a trimmed digest of the last few user/assistant messages, oldest first.
|
|
31
|
+
* Pure over a session branch's entries so it can be unit-tested.
|
|
32
|
+
*/
|
|
33
|
+
export function digestMessages(entries: readonly unknown[], opts: ContextWindow): string {
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
for (let i = entries.length - 1; i >= 0 && parts.length < opts.messages; i--) {
|
|
36
|
+
const entry = entries[i] as {
|
|
37
|
+
type?: string;
|
|
38
|
+
message?: { role?: string; content?: string | Array<{ type: string; text?: string }> };
|
|
39
|
+
};
|
|
40
|
+
if (!entry || entry.type !== "message") continue;
|
|
41
|
+
const msg = entry.message;
|
|
42
|
+
if (!msg || typeof msg !== "object" || !("role" in msg)) continue;
|
|
43
|
+
if (msg.role !== "user" && msg.role !== "assistant") continue;
|
|
44
|
+
const text = extractTextContent(msg.content).trim();
|
|
45
|
+
if (!text) continue;
|
|
46
|
+
const label = msg.role === "user" ? "User" : "Assistant";
|
|
47
|
+
parts.unshift(`${label}: ${text.slice(0, opts.chars)}`);
|
|
48
|
+
}
|
|
49
|
+
return parts.join("\n\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function conversationDigest(ctx: ExtensionContext, opts: ContextWindow): string {
|
|
53
|
+
return digestMessages(ctx.sessionManager.getBranch(), opts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildUserMessage(
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
partial: string,
|
|
59
|
+
opts: ContextWindow,
|
|
60
|
+
): UserMessage {
|
|
61
|
+
const digest = conversationDigest(ctx, opts) || "(no conversation yet)";
|
|
62
|
+
const inputSection = partial
|
|
63
|
+
? `Partial input (must be the exact start of your output):\n"""${partial}"""`
|
|
64
|
+
: `The input box is empty. Predict the user's next prompt.`;
|
|
65
|
+
return {
|
|
66
|
+
role: "user",
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: "text",
|
|
70
|
+
text: `Recent conversation (oldest first):\n${digest}\n\n${inputSection}`,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
timestamp: Date.now(),
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in debug logging for prompt suggestions.
|
|
3
|
+
*
|
|
4
|
+
* Suggestions are advisory, so failures are never surfaced to the user as
|
|
5
|
+
* errors. Logging is off by default — pi's TUI captures console output and
|
|
6
|
+
* shows it in the chat area, so even stderr logging is user-visible noise.
|
|
7
|
+
* Enable it with `"debug": true` in the config; lines are then appended to
|
|
8
|
+
* the configured log file (default ~/.pi/agent/prompt-suggestions.log) so
|
|
9
|
+
* "no suggestion appeared" can be diagnosed after the fact.
|
|
10
|
+
*
|
|
11
|
+
* No pi imports — this module is unit-testable with plain Node.
|
|
12
|
+
*
|
|
13
|
+
* Categories:
|
|
14
|
+
* - "timeout" generation exceeded the timeout
|
|
15
|
+
* - "error" model/network error, or model reported stopReason error
|
|
16
|
+
* - "no-suggestion" model returned nothing usable (NONE, empty, rejected)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
|
|
22
|
+
export type DebugCategory = "timeout" | "error" | "no-suggestion";
|
|
23
|
+
|
|
24
|
+
const PREFIX = "[prompt-suggestions]";
|
|
25
|
+
|
|
26
|
+
let enabled = false;
|
|
27
|
+
let logFile: string | null = null;
|
|
28
|
+
|
|
29
|
+
export function configureDebug(options: { enabled: boolean; logFile?: string }): void {
|
|
30
|
+
enabled = options.enabled;
|
|
31
|
+
if (options.logFile) logFile = options.logFile;
|
|
32
|
+
// Marker line so an existing log confirms debug is wired up; an absent
|
|
33
|
+
// log then unambiguously means the extension/config never loaded.
|
|
34
|
+
if (enabled) write("enabled");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function debug(category: DebugCategory, detail?: string): void {
|
|
38
|
+
if (!enabled) return;
|
|
39
|
+
write(detail ? `${category}: ${detail}` : category);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function write(message: string): void {
|
|
43
|
+
if (!logFile) return;
|
|
44
|
+
const line = `${new Date().toISOString()} ${PREFIX} ${message}\n`;
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
47
|
+
fs.appendFileSync(logFile, line, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
// Debug logging must never break suggestions.
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ghost-text editor component.
|
|
3
|
+
*
|
|
4
|
+
* Extends pi's CustomEditor and renders suggestions as dim ghost text after
|
|
5
|
+
* the cursor. Owns debounce, in-flight cancellation, streaming, multi-candidate
|
|
6
|
+
* cycling, and a per-turn cost guard.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
14
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
15
|
+
import { CURSOR_MARKER, Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { Config } from "./config.ts";
|
|
18
|
+
import { afterTurnEnabled, whileTypingEnabled } from "./mode.ts";
|
|
19
|
+
import { resolveSuggestionModel } from "./model-selection.ts";
|
|
20
|
+
import { buildUserMessage, type ContextWindow } from "./context.ts";
|
|
21
|
+
import {
|
|
22
|
+
classifyResponse,
|
|
23
|
+
GENERATION_TIMEOUT_MS,
|
|
24
|
+
MAX_TOKENS,
|
|
25
|
+
SYSTEM_PROMPT,
|
|
26
|
+
TEMPERATURE,
|
|
27
|
+
systemPromptFor,
|
|
28
|
+
} from "./client.ts";
|
|
29
|
+
import { extractSuggestion, fitSuggestionToWidth, isEligible, parseCandidates } from "./normalize.ts";
|
|
30
|
+
import { debug } from "./debug.ts";
|
|
31
|
+
|
|
32
|
+
const DEBOUNCE_MS = 700;
|
|
33
|
+
const STATUS_KEY = "prompt-suggestions";
|
|
34
|
+
|
|
35
|
+
export class SuggestingEditor extends CustomEditor {
|
|
36
|
+
private ghost: string | null = null; // visible remainder shown dimmed
|
|
37
|
+
private suggestionFull: string | null = null; // currently shown full suggestion
|
|
38
|
+
private candidates: string[] = []; // all fetched candidates
|
|
39
|
+
private candidateIndex = 0; // which candidate is shown
|
|
40
|
+
private baseText = ""; // editor text the suggestion was generated for
|
|
41
|
+
private debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
42
|
+
private abort: AbortController | undefined;
|
|
43
|
+
private requestSeq = 0;
|
|
44
|
+
private hintShown = false;
|
|
45
|
+
private shownThisTurn = 0; // cost/energy guard counter
|
|
46
|
+
private dim: (s: string) => string;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
tui: TUI,
|
|
50
|
+
theme: EditorTheme,
|
|
51
|
+
keybindings: KeybindingsManager,
|
|
52
|
+
private readonly ctx: ExtensionContext,
|
|
53
|
+
private readonly getConfig: () => Config,
|
|
54
|
+
) {
|
|
55
|
+
super(tui, theme, keybindings);
|
|
56
|
+
this.dim = (s: string) => ctx.ui.theme.fg("dim", s);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dispose(): void {
|
|
60
|
+
clearTimeout(this.debounceTimer);
|
|
61
|
+
this.abort?.abort();
|
|
62
|
+
this.clearGhost();
|
|
63
|
+
this.ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Agent has settled: reset the per-turn guard and offer a next prompt. */
|
|
67
|
+
onAgentSettled(): void {
|
|
68
|
+
this.shownThisTurn = 0;
|
|
69
|
+
this.suggestIfEmpty();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
suggestIfEmpty(): void {
|
|
73
|
+
if (!afterTurnEnabled(this.getConfig().mode)) return;
|
|
74
|
+
if (this.getText().trim().length !== 0) return;
|
|
75
|
+
void this.requestSuggestion(true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
clearGhost(): void {
|
|
79
|
+
if (this.ghost === null && this.suggestionFull === null) return;
|
|
80
|
+
this.ghost = null;
|
|
81
|
+
this.suggestionFull = null;
|
|
82
|
+
this.candidates = [];
|
|
83
|
+
this.candidateIndex = 0;
|
|
84
|
+
this.tui.requestRender();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private cursorAtEnd(): boolean {
|
|
88
|
+
const lines = this.getLines();
|
|
89
|
+
const cursor = this.getCursor();
|
|
90
|
+
return (
|
|
91
|
+
cursor.line === lines.length - 1 && cursor.col >= (lines[lines.length - 1] ?? "").length
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private contextOpts(): ContextWindow {
|
|
96
|
+
const c = this.getConfig();
|
|
97
|
+
return { messages: c.contextMessages, chars: c.contextChars };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private acceptSuggestion(): void {
|
|
101
|
+
if (!this.suggestionFull) return;
|
|
102
|
+
if (this.baseText) {
|
|
103
|
+
this.insertTextAtCursor(this.suggestionFull.slice(this.baseText.length));
|
|
104
|
+
} else {
|
|
105
|
+
this.setText(this.suggestionFull);
|
|
106
|
+
}
|
|
107
|
+
this.requestSeq++; // ignore any in-flight result
|
|
108
|
+
this.abort?.abort();
|
|
109
|
+
this.clearGhost();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Tab: cycle to the next candidate, else accept. Right arrow accepts directly. */
|
|
113
|
+
private cycleOrAccept(): void {
|
|
114
|
+
if (this.candidates.length > 1) {
|
|
115
|
+
const now = this.getText();
|
|
116
|
+
for (let i = this.candidateIndex + 1; i < this.candidates.length; i++) {
|
|
117
|
+
const candidate = this.candidates[i]!;
|
|
118
|
+
if (!now || candidate.startsWith(now)) {
|
|
119
|
+
this.candidateIndex = i;
|
|
120
|
+
this.baseText = now;
|
|
121
|
+
this.suggestionFull = candidate;
|
|
122
|
+
this.ghost = candidate.slice(now.length);
|
|
123
|
+
this.tui.requestRender();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.acceptSuggestion();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
override handleInput(data: string): void {
|
|
132
|
+
if (this.ghost) {
|
|
133
|
+
if (matchesKey(data, Key.tab) && !this.isShowingAutocomplete()) {
|
|
134
|
+
this.cycleOrAccept();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (matchesKey(data, Key.right) && this.cursorAtEnd()) {
|
|
138
|
+
this.acceptSuggestion();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (matchesKey(data, Key.escape)) {
|
|
142
|
+
this.clearGhost();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const before = this.getText();
|
|
148
|
+
super.handleInput(data);
|
|
149
|
+
const after = this.getText();
|
|
150
|
+
|
|
151
|
+
if (after !== before) {
|
|
152
|
+
this.onTextChanged(after);
|
|
153
|
+
} else if (this.ghost && !this.cursorAtEnd()) {
|
|
154
|
+
this.clearGhost();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private onTextChanged(text: string): void {
|
|
159
|
+
// Shrink the ghost locally while the typed text still prefixes the suggestion.
|
|
160
|
+
if (
|
|
161
|
+
this.suggestionFull &&
|
|
162
|
+
this.baseText &&
|
|
163
|
+
text.startsWith(this.baseText) &&
|
|
164
|
+
this.suggestionFull.startsWith(text)
|
|
165
|
+
) {
|
|
166
|
+
this.baseText = text;
|
|
167
|
+
this.ghost = this.suggestionFull.slice(text.length);
|
|
168
|
+
if (!this.ghost.trim()) {
|
|
169
|
+
this.clearGhost();
|
|
170
|
+
} else {
|
|
171
|
+
this.tui.requestRender();
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
this.clearGhost();
|
|
175
|
+
}
|
|
176
|
+
this.schedule();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private schedule(): void {
|
|
180
|
+
clearTimeout(this.debounceTimer);
|
|
181
|
+
this.abort?.abort(); // in-flight result is for stale text
|
|
182
|
+
this.debounceTimer = setTimeout(() => void this.requestSuggestion(false), DEBOUNCE_MS);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async requestSuggestion(allowEmpty: boolean): Promise<void> {
|
|
186
|
+
const config = this.getConfig();
|
|
187
|
+
if (allowEmpty ? !afterTurnEnabled(config.mode) : !whileTypingEnabled(config.mode)) return;
|
|
188
|
+
if (!this.ctx.isIdle()) return; // skip while the agent is streaming
|
|
189
|
+
if (config.maxPerTurn > 0 && this.shownThisTurn >= config.maxPerTurn) return;
|
|
190
|
+
|
|
191
|
+
const text = this.getText();
|
|
192
|
+
if (!isEligible(text, allowEmpty)) return;
|
|
193
|
+
|
|
194
|
+
const model = resolveSuggestionModel(this.ctx, config.model);
|
|
195
|
+
if (!model) return;
|
|
196
|
+
|
|
197
|
+
const seq = ++this.requestSeq;
|
|
198
|
+
const ac = new AbortController();
|
|
199
|
+
this.abort = ac;
|
|
200
|
+
this.ctx.ui.setStatus(STATUS_KEY, "suggesting…");
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const suggestions = await this.generateSuggestions(model, text, ac.signal, config);
|
|
204
|
+
if (seq !== this.requestSeq || ac.signal.aborted) return;
|
|
205
|
+
this.commitSuggestions(suggestions, text);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (seq !== this.requestSeq || ac.signal.aborted) return;
|
|
208
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
209
|
+
const isTimeout = /timeout|timed ?out/i.test(message);
|
|
210
|
+
debug(isTimeout ? "timeout" : "error", message);
|
|
211
|
+
this.clearGhost();
|
|
212
|
+
} finally {
|
|
213
|
+
if (seq === this.requestSeq) {
|
|
214
|
+
this.ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Generation ────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
private async generateSuggestions(
|
|
222
|
+
model: Model<any>,
|
|
223
|
+
text: string,
|
|
224
|
+
signal: AbortSignal,
|
|
225
|
+
config: Config,
|
|
226
|
+
): Promise<string[]> {
|
|
227
|
+
if (config.candidates === 1 && config.streaming) {
|
|
228
|
+
try {
|
|
229
|
+
return [await this.streamSingle(model, text, signal)];
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (signal.aborted) throw err; // interrupted by a newer keystroke, not a stream failure
|
|
232
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
233
|
+
debug("error", `streaming failed, falling back to complete: ${message}`);
|
|
234
|
+
this.clearGhost();
|
|
235
|
+
return this.completeCandidates(model, text, signal, 1);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return this.completeCandidates(model, text, signal, config.candidates);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async streamSingle(model: Model<any>, text: string, signal: AbortSignal): Promise<string> {
|
|
242
|
+
const auth = await this.ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
243
|
+
if (!auth.ok) throw new Error(auth.error);
|
|
244
|
+
|
|
245
|
+
const stream = streamSimple(model, {
|
|
246
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
247
|
+
messages: [buildUserMessage(this.ctx, text, this.contextOpts())],
|
|
248
|
+
}, {
|
|
249
|
+
apiKey: auth.apiKey ?? "",
|
|
250
|
+
headers: auth.headers,
|
|
251
|
+
env: auth.env,
|
|
252
|
+
signal,
|
|
253
|
+
maxTokens: MAX_TOKENS,
|
|
254
|
+
temperature: TEMPERATURE,
|
|
255
|
+
timeoutMs: GENERATION_TIMEOUT_MS,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
let acc = "";
|
|
259
|
+
for await (const event of stream) {
|
|
260
|
+
if (signal.aborted) throw new Error("aborted");
|
|
261
|
+
if (event.type === "text_delta") {
|
|
262
|
+
acc += event.delta;
|
|
263
|
+
this.updateStreamingGhost(acc, text);
|
|
264
|
+
} else if (event.type === "text_end") {
|
|
265
|
+
acc = event.content;
|
|
266
|
+
} else if (event.type === "error") {
|
|
267
|
+
throw new Error(event.error?.errorMessage ?? event.reason ?? "stream error");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return acc;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async completeCandidates(
|
|
274
|
+
model: Model<any>,
|
|
275
|
+
text: string,
|
|
276
|
+
signal: AbortSignal,
|
|
277
|
+
count: number,
|
|
278
|
+
): Promise<string[]> {
|
|
279
|
+
const response = await this.ctx.modelRegistry.complete(model, {
|
|
280
|
+
systemPrompt: systemPromptFor(count),
|
|
281
|
+
messages: [buildUserMessage(this.ctx, text, this.contextOpts())],
|
|
282
|
+
}, {
|
|
283
|
+
signal,
|
|
284
|
+
maxTokens: MAX_TOKENS * count,
|
|
285
|
+
temperature: TEMPERATURE,
|
|
286
|
+
timeoutMs: GENERATION_TIMEOUT_MS,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const classified = classifyResponse(response);
|
|
290
|
+
if (!classified.ok) {
|
|
291
|
+
debug(classified.reason === "error" ? "error" : "no-suggestion", classified.message);
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
return parseCandidates(classified.text, count);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Show the accumulated text as a partial ghost while streaming. */
|
|
298
|
+
private updateStreamingGhost(acc: string, baseText: string): void {
|
|
299
|
+
if (this.getText() !== baseText) return; // user typed more; ignore this stream
|
|
300
|
+
if (!acc.startsWith(baseText)) return; // model deviated; wait for final validation
|
|
301
|
+
const remainder = acc.slice(baseText.length);
|
|
302
|
+
if (!remainder) return;
|
|
303
|
+
this.baseText = baseText;
|
|
304
|
+
this.suggestionFull = acc;
|
|
305
|
+
this.ghost = remainder;
|
|
306
|
+
this.tui.requestRender();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Commit ────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
private commitSuggestions(raw: string[], text: string): void {
|
|
312
|
+
const now = this.getText();
|
|
313
|
+
const candidates: string[] = [];
|
|
314
|
+
for (const rawCandidate of raw) {
|
|
315
|
+
const suggestion = extractSuggestion(rawCandidate, text);
|
|
316
|
+
if (!suggestion) continue;
|
|
317
|
+
if (now !== text) {
|
|
318
|
+
// The user typed more while the request was in flight.
|
|
319
|
+
if (!now || !suggestion.startsWith(now) || suggestion.length <= now.length) continue;
|
|
320
|
+
}
|
|
321
|
+
if (!candidates.includes(suggestion)) candidates.push(suggestion);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (candidates.length === 0) {
|
|
325
|
+
debug("no-suggestion");
|
|
326
|
+
this.clearGhost();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const baseText = now !== text ? now : text;
|
|
331
|
+
this.candidates = candidates;
|
|
332
|
+
this.candidateIndex = 0;
|
|
333
|
+
this.baseText = baseText;
|
|
334
|
+
this.suggestionFull = candidates[0]!;
|
|
335
|
+
this.ghost = candidates[0]!.slice(baseText.length);
|
|
336
|
+
|
|
337
|
+
if (!this.ghost || !this.cursorAtEnd()) {
|
|
338
|
+
this.clearGhost();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
this.shownThisTurn++;
|
|
343
|
+
if (!this.hintShown) {
|
|
344
|
+
this.hintShown = true;
|
|
345
|
+
this.ctx.ui.notify("Prompt suggestions: Tab or → to accept, /suggest to configure", "info");
|
|
346
|
+
}
|
|
347
|
+
this.tui.requestRender();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
override render(width: number): string[] {
|
|
351
|
+
const lines = super.render(width);
|
|
352
|
+
if (!this.ghost) return lines;
|
|
353
|
+
|
|
354
|
+
// The focused editor emits CURSOR_MARKER right before the fake cursor.
|
|
355
|
+
// Inserting the ghost after the cursor keeps this independent of the
|
|
356
|
+
// editor's internal wrap/scroll layout.
|
|
357
|
+
const idx = lines.findIndex((l) => l.includes(CURSOR_MARKER));
|
|
358
|
+
if (idx === -1) return lines;
|
|
359
|
+
|
|
360
|
+
const line = lines[idx]!;
|
|
361
|
+
const markerPos = line.indexOf(CURSOR_MARKER);
|
|
362
|
+
let pos = markerPos + CURSOR_MARKER.length;
|
|
363
|
+
|
|
364
|
+
// Skip over the inverse-video cursor glyph that follows the marker.
|
|
365
|
+
const rest = line.slice(pos);
|
|
366
|
+
if (rest.startsWith("\x1b[7m")) {
|
|
367
|
+
const end = rest.indexOf("\x1b[27m");
|
|
368
|
+
if (end !== -1) pos += end + "\x1b[27m".length;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const before = line.slice(0, pos);
|
|
372
|
+
const after = line.slice(pos);
|
|
373
|
+
if (visibleWidth(after) > 0) return lines; // only ghost at end of content
|
|
374
|
+
|
|
375
|
+
const room = width - visibleWidth(before) - 1;
|
|
376
|
+
if (room < 6) return lines;
|
|
377
|
+
|
|
378
|
+
const shown = fitSuggestionToWidth(this.ghost.replace(/\s+/g, " "), room, visibleWidth);
|
|
379
|
+
|
|
380
|
+
// Replace an equal amount of trailing padding so the line stays within width.
|
|
381
|
+
const shownWidth = visibleWidth(shown);
|
|
382
|
+
lines[idx] = before + this.dim(shown) + after.slice(shownWidth);
|
|
383
|
+
return lines;
|
|
384
|
+
}
|
|
385
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Suggestion - Claude Code-style ghost text in the prompt editor.
|
|
3
|
+
*
|
|
4
|
+
* As you type (or when the agent finishes and the input box is empty), a dimmed
|
|
5
|
+
* suggestion of the likely prompt appears after the cursor.
|
|
6
|
+
*
|
|
7
|
+
* - Tab cycles candidates (or accepts) · Right arrow accepts
|
|
8
|
+
* - Keep typing: the ghost shrinks if it still matches, otherwise it regenerates
|
|
9
|
+
* - Escape / cursor movement away from the end: dismiss
|
|
10
|
+
* - /suggest: choose mode (off / after-turn / while-typing / both)
|
|
11
|
+
* - /suggest-model: pick the model used for suggestions
|
|
12
|
+
* - /suggest-context: set context window (messages + chars per message)
|
|
13
|
+
*
|
|
14
|
+
* Config (resolved defaults <- global <- project):
|
|
15
|
+
* global: ~/.pi/agent/prompt-suggestions.json
|
|
16
|
+
* project: .pi/prompt-suggestions.json
|
|
17
|
+
*
|
|
18
|
+
* Install as a pi package: pi install /path/to/pi-ghost-text
|
|
19
|
+
* Quick test: pi -e ./src/index.ts
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
CONFIG_DIR_NAME,
|
|
26
|
+
type ExtensionAPI,
|
|
27
|
+
type ExtensionContext,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import * as os from "node:os";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_CONFIG,
|
|
33
|
+
readConfig,
|
|
34
|
+
writeGlobalConfig,
|
|
35
|
+
type Config,
|
|
36
|
+
} from "./config.ts";
|
|
37
|
+
import type { SuggestMode } from "./mode.ts";
|
|
38
|
+
import { scopedOrAvailable } from "./model-selection.ts";
|
|
39
|
+
import { configureDebug } from "./debug.ts";
|
|
40
|
+
import { extractTextContent } from "./context.ts";
|
|
41
|
+
import { SuggestingEditor } from "./editor.ts";
|
|
42
|
+
|
|
43
|
+
const MODES: Array<{ label: string; value: SuggestMode }> = [
|
|
44
|
+
{ label: "both (while typing + after turn)", value: "both" },
|
|
45
|
+
{ label: "while-typing (inline autocomplete)", value: "while-typing" },
|
|
46
|
+
{ label: "after-turn (next prompt when empty)", value: "after-turn" },
|
|
47
|
+
{ label: "off", value: "off" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export default function (pi: ExtensionAPI): void {
|
|
51
|
+
let config: Config = DEFAULT_CONFIG;
|
|
52
|
+
let editor: SuggestingEditor | null = null;
|
|
53
|
+
|
|
54
|
+
pi.registerCommand("suggest", {
|
|
55
|
+
description: "Choose when prompt suggestions appear",
|
|
56
|
+
handler: async (_args, ctx) => {
|
|
57
|
+
const choice = await ctx.ui.select(
|
|
58
|
+
"Suggestion mode:",
|
|
59
|
+
MODES.map((m) => m.label),
|
|
60
|
+
);
|
|
61
|
+
if (!choice) return;
|
|
62
|
+
const mode = MODES.find((m) => m.label === choice)?.value ?? "off";
|
|
63
|
+
config.mode = mode;
|
|
64
|
+
writeGlobalConfig({ mode });
|
|
65
|
+
if (!whileTypingOrAfterTurn(mode)) editor?.clearGhost();
|
|
66
|
+
ctx.ui.notify(`Prompt suggestions: ${mode}`, "info");
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
pi.registerCommand("suggest-model", {
|
|
71
|
+
description: "Choose the model used for prompt suggestions",
|
|
72
|
+
handler: async (_args, ctx) => {
|
|
73
|
+
const pool = scopedOrAvailable(ctx);
|
|
74
|
+
const entries = [
|
|
75
|
+
{ label: "auto (auto-pick a fast, cheap model)", value: "auto" },
|
|
76
|
+
...pool.map((m) => ({ label: `${m.provider}/${m.id}`, value: `${m.provider}/${m.id}` })),
|
|
77
|
+
];
|
|
78
|
+
const choice = await ctx.ui.select("Suggestion model:", entries.map((e) => e.label));
|
|
79
|
+
if (!choice) return;
|
|
80
|
+
const picked = entries.find((e) => e.label === choice);
|
|
81
|
+
const value = picked ? picked.value : "auto";
|
|
82
|
+
config.model = value;
|
|
83
|
+
writeGlobalConfig({ model: value });
|
|
84
|
+
ctx.ui.notify(`Suggestion model: ${value}`, "info");
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.registerCommand("suggest-context", {
|
|
89
|
+
description: "Set the conversation context window for suggestions",
|
|
90
|
+
handler: async (_args, ctx) => {
|
|
91
|
+
const messages = await ctx.ui.input(
|
|
92
|
+
"Context: number of recent messages",
|
|
93
|
+
String(config.contextMessages),
|
|
94
|
+
);
|
|
95
|
+
if (messages === undefined) return;
|
|
96
|
+
const chars = await ctx.ui.input(
|
|
97
|
+
"Context: chars kept per message",
|
|
98
|
+
String(config.contextChars),
|
|
99
|
+
);
|
|
100
|
+
if (chars === undefined) return;
|
|
101
|
+
|
|
102
|
+
const messagesCount = clampInt(Number(messages), 0, 50, config.contextMessages);
|
|
103
|
+
const charsCount = clampInt(Number(chars), 0, 10_000, config.contextChars);
|
|
104
|
+
config.contextMessages = messagesCount;
|
|
105
|
+
config.contextChars = charsCount;
|
|
106
|
+
writeGlobalConfig({ contextMessages: messagesCount, contextChars: charsCount });
|
|
107
|
+
ctx.ui.notify(`Suggestion context: ${messagesCount} messages × ${charsCount} chars`, "info");
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
pi.on("session_start", (_event, ctx) => {
|
|
112
|
+
config = readConfig(ctx.cwd);
|
|
113
|
+
configureDebug({ enabled: config.debug, logFile: defaultLogFile() });
|
|
114
|
+
if (ctx.mode !== "tui") return;
|
|
115
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
116
|
+
editor = new SuggestingEditor(tui, theme, keybindings, ctx, () => config);
|
|
117
|
+
seedHistoryFromSession(editor, ctx);
|
|
118
|
+
return editor;
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Suggest a next prompt when the agent finishes and the input box is empty.
|
|
123
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
124
|
+
if (ctx.mode !== "tui") return;
|
|
125
|
+
editor?.onAgentSettled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.on("before_agent_start", () => {
|
|
129
|
+
editor?.clearGhost();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
pi.on("session_shutdown", () => {
|
|
133
|
+
editor?.dispose();
|
|
134
|
+
editor = null;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function whileTypingOrAfterTurn(mode: SuggestMode): boolean {
|
|
139
|
+
return mode !== "off";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function defaultLogFile(): string {
|
|
143
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME, "agent", "prompt-suggestions.log");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function clampInt(value: number, min: number, max: number, fallback: number): number {
|
|
147
|
+
if (!Number.isFinite(value)) return fallback;
|
|
148
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Repopulate the editor's UP-arrow history from the session branch so it
|
|
153
|
+
* survives `/reload` (which reinstantiates the editor with empty history).
|
|
154
|
+
*/
|
|
155
|
+
function seedHistoryFromSession(editor: SuggestingEditor, ctx: ExtensionContext): void {
|
|
156
|
+
const branch = ctx.sessionManager.getBranch();
|
|
157
|
+
for (const entry of branch) {
|
|
158
|
+
if (entry.type !== "message") continue;
|
|
159
|
+
const msg = entry.message;
|
|
160
|
+
if (!("role" in msg) || msg.role !== "user") continue;
|
|
161
|
+
const text = extractTextContent(msg.content).trim();
|
|
162
|
+
if (text) editor.addToHistory(text);
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/mode.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestion modes — pure, no pi imports (unit-testable).
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type SuggestMode = "off" | "after-turn" | "while-typing" | "both";
|
|
8
|
+
|
|
9
|
+
export function afterTurnEnabled(mode: SuggestMode): boolean {
|
|
10
|
+
return mode === "after-turn" || mode === "both";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function whileTypingEnabled(mode: SuggestMode): boolean {
|
|
14
|
+
return mode === "while-typing" || mode === "both";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isMode(value: unknown): value is SuggestMode {
|
|
18
|
+
return value === "off" || value === "after-turn" || value === "while-typing" || value === "both";
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggestion model resolution.
|
|
3
|
+
*
|
|
4
|
+
* Only `import type` from pi packages, so the pure helpers here are
|
|
5
|
+
* unit-testable with plain Node.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
/** Prefer a fast, cheap model for suggestions. */
|
|
14
|
+
export const MODEL_PREFERENCES = [/haiku/i, /gpt-.*mini/i, /flash/i, /nano/i];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pick the first pool model matching a preference pattern, else the fallback.
|
|
18
|
+
*/
|
|
19
|
+
export function pickAutoModel(
|
|
20
|
+
pool: Model<any>[],
|
|
21
|
+
fallback: Model<any> | undefined,
|
|
22
|
+
): Model<any> | undefined {
|
|
23
|
+
for (const pattern of MODEL_PREFERENCES) {
|
|
24
|
+
const match = pool.find((m) => pattern.test(m.id));
|
|
25
|
+
if (match) return match;
|
|
26
|
+
}
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Scoped models when scoping is configured, else every available model. */
|
|
31
|
+
export function scopedOrAvailable(ctx: ExtensionContext): Model<any>[] {
|
|
32
|
+
const scoped = ctx.scopedModels.map((s) => s.model);
|
|
33
|
+
return scoped.length > 0 ? scoped : ctx.modelRegistry.getAvailable();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveSuggestionModel(
|
|
37
|
+
ctx: ExtensionContext,
|
|
38
|
+
modelSetting: string,
|
|
39
|
+
): Model<any> | undefined {
|
|
40
|
+
const pool = scopedOrAvailable(ctx);
|
|
41
|
+
if (modelSetting === "auto") return pickAutoModel(pool, ctx.model);
|
|
42
|
+
|
|
43
|
+
const match = pool.find((m) => `${m.provider}/${m.id}` === modelSetting);
|
|
44
|
+
return match ?? ctx.model;
|
|
45
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure text helpers for suggestion extraction and truncation.
|
|
3
|
+
*
|
|
4
|
+
* No pi imports — this module is unit-testable with plain Node.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MAX_SUGGESTION_GRAPHEMES = 300;
|
|
10
|
+
export const MIN_CHARS = 4;
|
|
11
|
+
|
|
12
|
+
const NO_SUGGESTION = /^NONE$/i;
|
|
13
|
+
|
|
14
|
+
// ── Grapheme helpers ───────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export function splitGraphemes(text: string): string[] {
|
|
17
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
18
|
+
return Array.from(segmenter.segment(text), (segment) => segment.segment);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function countGraphemes(text: string): number {
|
|
22
|
+
return splitGraphemes(text).length;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Truncate to at most `max` grapheme clusters without splitting any. */
|
|
26
|
+
export function truncateGraphemes(text: string, max: number): string {
|
|
27
|
+
const graphemes = splitGraphemes(text);
|
|
28
|
+
if (graphemes.length <= max) return text;
|
|
29
|
+
return graphemes.slice(0, max).join("").trimEnd();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Width-aware truncation ─────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Truncate to fit a visible-width budget, appending an ellipsis when cut.
|
|
36
|
+
*
|
|
37
|
+
* Widths are measured by the caller-supplied `measure` function so this stays
|
|
38
|
+
* pure and testable; the editor passes pi-tui's `visibleWidth`, which counts
|
|
39
|
+
* terminal columns (wide CJK chars = 2, combining marks = 0).
|
|
40
|
+
*/
|
|
41
|
+
export function truncateToWidth(
|
|
42
|
+
text: string,
|
|
43
|
+
maxWidth: number,
|
|
44
|
+
measure: (s: string) => number,
|
|
45
|
+
ellipsis = "…",
|
|
46
|
+
): string {
|
|
47
|
+
if (measure(text) <= maxWidth) return text;
|
|
48
|
+
|
|
49
|
+
const graphemes = splitGraphemes(text);
|
|
50
|
+
let out = "";
|
|
51
|
+
for (const g of graphemes) {
|
|
52
|
+
if (measure(out + g) + measure(ellipsis) > maxWidth) break;
|
|
53
|
+
out += g;
|
|
54
|
+
}
|
|
55
|
+
return `${out.trimEnd()}${ellipsis}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Truncate a suggestion to fit `maxWidth`, preferring to break on a word
|
|
60
|
+
* boundary when the cut would otherwise happen mid-word (beyond `minWordKeep`).
|
|
61
|
+
*/
|
|
62
|
+
export function fitSuggestionToWidth(
|
|
63
|
+
text: string,
|
|
64
|
+
maxWidth: number,
|
|
65
|
+
measure: (s: string) => number,
|
|
66
|
+
minWordKeep = 10,
|
|
67
|
+
): string {
|
|
68
|
+
if (measure(text) <= maxWidth) return text;
|
|
69
|
+
|
|
70
|
+
const truncated = truncateToWidth(text, maxWidth, measure);
|
|
71
|
+
const content = truncated.endsWith("…") ? truncated.slice(0, -1) : truncated;
|
|
72
|
+
const lastSpace = content.lastIndexOf(" ");
|
|
73
|
+
if (lastSpace > minWordKeep) {
|
|
74
|
+
return `${content.slice(0, lastSpace).trimEnd()}…`;
|
|
75
|
+
}
|
|
76
|
+
return truncated;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Normalization & extraction ─────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalize raw model output into a suggestion, or null when unusable.
|
|
83
|
+
*
|
|
84
|
+
* Trims, rejects the NONE sentinel, strips wrapping quotes/backticks, and
|
|
85
|
+
* applies the grapheme safety cap.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeSuggestionText(text: string): string | null {
|
|
88
|
+
let normalized = text.trim();
|
|
89
|
+
if (!normalized || NO_SUGGESTION.test(normalized)) return null;
|
|
90
|
+
|
|
91
|
+
normalized = normalized.replace(/^["'`]+|["'`]+$/g, "").trim();
|
|
92
|
+
if (!normalized || NO_SUGGESTION.test(normalized)) return null;
|
|
93
|
+
|
|
94
|
+
if (countGraphemes(normalized) > MAX_SUGGESTION_GRAPHEMES) return null;
|
|
95
|
+
return normalized;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extract a suggestion from raw model text, validating the partial-input
|
|
100
|
+
* prefix contract. Returns null when there is no usable suggestion.
|
|
101
|
+
*/
|
|
102
|
+
export function extractSuggestion(rawText: string, partial: string): string | null {
|
|
103
|
+
const text = normalizeSuggestionText(rawText);
|
|
104
|
+
if (!text) return null;
|
|
105
|
+
|
|
106
|
+
if (partial) {
|
|
107
|
+
if (!text.startsWith(partial)) return null;
|
|
108
|
+
if (!text.slice(partial.length).trim()) return null;
|
|
109
|
+
}
|
|
110
|
+
return text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether the current editor text is eligible for a suggestion. */
|
|
114
|
+
export function isEligible(text: string, allowEmpty: boolean): boolean {
|
|
115
|
+
if (text.startsWith("/") || text.startsWith("!")) return false; // commands
|
|
116
|
+
if (/@[^\s]*$/.test(text)) return false; // @file completion in progress
|
|
117
|
+
if (text.trim().length === 0) return allowEmpty;
|
|
118
|
+
return text.length >= MIN_CHARS;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Multi-candidate parsing ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
const CANDIDATE_SEPARATOR = /^\s*---\s*$/m;
|
|
124
|
+
|
|
125
|
+
/** Split a multi-alternative model response into up to `count` raw candidates. */
|
|
126
|
+
export function parseCandidates(rawText: string, count: number): string[] {
|
|
127
|
+
return rawText
|
|
128
|
+
.split(CANDIDATE_SEPARATOR)
|
|
129
|
+
.map((c) => c.trim())
|
|
130
|
+
.filter((c) => c.length > 0)
|
|
131
|
+
.slice(0, count);
|
|
132
|
+
}
|