pi-next-cue 1.0.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.
@@ -0,0 +1,23 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 20
19
+ registry-url: https://registry.npmjs.org
20
+
21
+ - run: npm publish --provenance --access public
22
+ env:
23
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ouzhenkun
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,81 @@
1
+ # pi-next-cue
2
+
3
+ **Predicts your next prompt after each agent turn — Tab to fill, Enter to send.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/pi-next-cue?style=for-the-badge)](https://www.npmjs.com/package/pi-next-cue)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
7
+
8
+ ## Why
9
+
10
+ After the agent finishes, you usually know what to type next. Pi-next-cue predicts it and shows a hint above your editor — saving keystrokes and keeping you in flow.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pi install npm:pi-next-cue
16
+ ```
17
+
18
+ Requires Pi v0.37.0+.
19
+
20
+ ## How It Works
21
+
22
+ The hint widget shows **one** of two states, depending on context:
23
+
24
+ **Predicted prompt** (after agent responds):
25
+ ```
26
+ → run the tests
27
+ ```
28
+
29
+ **Your last message** (for reference):
30
+ ```
31
+ ↩ fix the login redirect bug
32
+ ```
33
+
34
+ - **→** Next prompt you're likely to send — generated by a lightweight LLM after each agent turn
35
+ - **↩** The last message you sent — so you never lose context of what you asked
36
+
37
+ When the editor is empty:
38
+ - **Tab** fills the hint into the editor for editing
39
+ - **Enter** sends the hint directly
40
+
41
+ When you start typing, the hint stays visible but won't interfere — Tab and Enter only trigger when the editor is empty.
42
+
43
+ ## Features
44
+
45
+ - **Adaptive tone** — matches your language and communication style from recent messages
46
+ - **Correction learning** — remembers when you ignore suggestions, avoids repeating rejected directions
47
+ - **Tool-aware** — considers whether the last tool succeeded or failed when predicting
48
+ - **Lightweight** — single LLM call with minimal context, no background indexing
49
+
50
+ ## Configuration
51
+
52
+ Pi-next-cue uses your session's current model by default. To use a specific fast/cheap model or customize keybindings, create `~/.pi/agent/pi-next-cue.json`:
53
+
54
+ ```json
55
+ {
56
+ "provider": "deepseek",
57
+ "model": "deepseek-chat",
58
+ "keys": {
59
+ "fill": "tab",
60
+ "send": "enter"
61
+ }
62
+ }
63
+ ```
64
+
65
+ All fields are optional. Defaults: `tab` to fill, `enter` to send. Key names follow Pi's [keybinding format](https://pi.dev/docs/latest/keybindings).
66
+
67
+ ## Events
68
+
69
+ Other extensions can control hint visibility:
70
+
71
+ ```typescript
72
+ // Hide the hint (e.g. when showing a dialog)
73
+ pi.events.emit("pi-next-cue:pause");
74
+
75
+ // Restore the hint
76
+ pi.events.emit("pi-next-cue:resume");
77
+ ```
78
+
79
+ ## License
80
+
81
+ MIT
package/index.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * pi-next-cue
3
+ *
4
+ * Predicts your next prompt after each agent turn. Shows a hint widget above
5
+ * the editor with two states:
6
+ * ↩ your last input (recall)
7
+ * → predicted next prompt (cue)
8
+ *
9
+ * Tab fills the hint into the editor; Enter sends it directly.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import {
15
+ CustomEditor,
16
+ getAgentDir,
17
+ type ExtensionAPI,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import { complete, type UserMessage } from "@earendil-works/pi-ai";
20
+ import { matchesKey } from "@earendil-works/pi-tui";
21
+
22
+ const SYSTEM_PROMPT = `Predict the user's most likely next reply as one short message.
23
+
24
+ What to suggest:
25
+ - Task completed -> the next logical workflow step
26
+ - Confirmation requested -> likely yes/no/choice
27
+ - Options presented -> likely pick, if context supports one
28
+ - Open-ended question -> a concrete answer or direction, only if inferable
29
+ - Tool failed -> a specific retry/fix based on the failure
30
+ - Tool succeeded -> the next useful step
31
+ - Agent proposed a clear next action -> a short affirmation is often enough
32
+ - If context is too thin to predict a useful reply, return [skip]
33
+
34
+ Tone:
35
+ - Match the language and register of recent user messages
36
+ - Casual when the moment is casual, direct when there is momentum
37
+ - Do not force excitement, praise, or drama
38
+
39
+ Rules:
40
+ - Return ONE message, under 60 chars, nothing else
41
+ - If no useful suggestion exists, return exactly: [skip]
42
+ - The suggestion must advance or unblock the workflow
43
+ - A short confirmation counts as advancing when it lets the agent proceed
44
+ - Be specific to this conversation, not generic
45
+ - Never repeat or rephrase the assistant's last message
46
+ - Never suggest an action the assistant already completed
47
+ - If the assistant only offered a pending action, a brief confirmation is allowed
48
+ - Learn from user corrections in the conversation; avoid rejected directions
49
+ - If a slash command is the obvious next action, suggest only the command
50
+ - Never output a generic question like "what's next" or "what should I do" — return [skip] instead`;
51
+
52
+ const SKIP_TOKEN = "[skip]";
53
+ const MAX_CORRECTIONS = 5;
54
+
55
+ type HintType = "recall" | "cue";
56
+
57
+ export default function (pi: ExtensionAPI) {
58
+ let currentHint: string | null = null;
59
+ let hintType: HintType | null = null;
60
+ let widgetCtx: any = null;
61
+ let suggestionAbort: AbortController | null = null;
62
+
63
+ // Correction tracking
64
+ const corrections: Array<{ suggested: string; actual: string }> = [];
65
+ let lastSuggestion: string | null = null;
66
+
67
+ // Tool outcome tracking
68
+ let lastToolOutcome: { tool: string; ok: boolean; tail: string } | null =
69
+ null;
70
+
71
+ function setHint(text: string | null, type: HintType | null) {
72
+ currentHint = text;
73
+ hintType = type;
74
+ if (!widgetCtx || paused) return;
75
+ if (!text) {
76
+ widgetCtx.ui.setWidget("hint", undefined);
77
+ return;
78
+ }
79
+ const icon = type === "recall" ? "↩" : "→";
80
+ const display = text.length > 80 ? text.slice(0, 77) + "..." : text;
81
+ widgetCtx.ui.setWidget("hint", [
82
+ `\x1b[38;5;240m${icon} ${display.replace(/\n/g, " ")}\x1b[0m`,
83
+ ]);
84
+ }
85
+
86
+ // Pause/resume hint visibility (for dialogs, overlays, etc.)
87
+ let paused = false;
88
+
89
+ function pauseHint() {
90
+ paused = true;
91
+ if (widgetCtx) widgetCtx.ui.setWidget("hint", undefined);
92
+ }
93
+
94
+ function resumeHint() {
95
+ paused = false;
96
+ if (currentHint) setHint(currentHint, hintType);
97
+ }
98
+
99
+ pi.events.on("pi-next-cue:pause", pauseHint);
100
+ pi.events.on("pi-next-cue:resume", resumeHint);
101
+
102
+ // Load config from ~/.pi/agent/pi-next-cue.json
103
+ let userConfig: { provider?: string; model?: string; keys?: { fill?: string; send?: string } } = {};
104
+ try {
105
+ const configPath = path.join(getAgentDir(), "pi-next-cue.json");
106
+ const raw = fs.readFileSync(configPath, "utf-8");
107
+ userConfig = JSON.parse(raw);
108
+ } catch {
109
+ // No config file — use defaults
110
+ }
111
+
112
+ const fillKey = userConfig.keys?.fill || "tab";
113
+ const sendKey = userConfig.keys?.send || "enter";
114
+
115
+ /**
116
+ * Resolve the model to use for suggestion generation.
117
+ * Tries user config first, then falls back to session default.
118
+ */
119
+ function resolveModel(ctx: any) {
120
+ if (userConfig.provider && userConfig.model) {
121
+ const found = ctx.modelRegistry.find(userConfig.provider, userConfig.model);
122
+ if (found) return found;
123
+ }
124
+ return ctx.model;
125
+ }
126
+
127
+ async function generateSuggestion(ctx: any) {
128
+ if (suggestionAbort) {
129
+ suggestionAbort.abort();
130
+ suggestionAbort = null;
131
+ }
132
+
133
+ const model = resolveModel(ctx);
134
+ if (!model) return;
135
+
136
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
137
+ if (!auth.ok || !auth.apiKey) return;
138
+
139
+ // Gather recent messages for context
140
+ const branch = ctx.sessionManager.getBranch();
141
+ const recentMessages: Array<{ role: string; text: string }> = [];
142
+ const recentTools: string[] = [];
143
+
144
+ for (let i = branch.length - 1; i >= 0 && recentMessages.length < 6; i--) {
145
+ const entry = branch[i];
146
+ if (entry.type !== "message") continue;
147
+ const msg = entry.message;
148
+ if (!("role" in msg)) continue;
149
+
150
+ if (msg.role === "toolResult" && recentTools.length < 5) {
151
+ const toolName = msg.toolName || "unknown";
152
+ if (!recentTools.includes(toolName)) recentTools.push(toolName);
153
+ continue;
154
+ }
155
+
156
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
157
+
158
+ const textParts = msg.content
159
+ .filter((c: any) => c.type === "text")
160
+ .map((c: any) => c.text)
161
+ .join("\n");
162
+
163
+ if (textParts.trim()) {
164
+ const maxLen = msg.role === "assistant" ? 1000 : 500;
165
+ const text =
166
+ msg.role === "assistant" && textParts.length > maxLen
167
+ ? "..." + textParts.slice(-maxLen)
168
+ : textParts.slice(0, maxLen);
169
+ recentMessages.unshift({ role: msg.role, text });
170
+ }
171
+ }
172
+
173
+ if (recentMessages.length === 0) return;
174
+
175
+ // Build context
176
+ const parts: string[] = [];
177
+
178
+ if (corrections.length > 0) {
179
+ const corrLines = corrections
180
+ .slice(-3)
181
+ .map((c) => `- suggested "${c.suggested}" → user typed "${c.actual}"`)
182
+ .join("\n");
183
+ parts.push(`[Corrections]\n${corrLines}`);
184
+ }
185
+
186
+ if (lastToolOutcome) {
187
+ const status = lastToolOutcome.ok ? "✓" : "✗";
188
+ parts.push(
189
+ `[Last Tool] ${status} ${lastToolOutcome.tool}: ${lastToolOutcome.tail}`,
190
+ );
191
+ }
192
+
193
+ if (recentTools.length > 0) {
194
+ parts.push(`[Tools Used] ${recentTools.join(", ")}`);
195
+ }
196
+
197
+ const contextLines = recentMessages.map((m, i) => {
198
+ const prefix = m.role === "user" ? "User" : "Assistant";
199
+ const isLast = i === recentMessages.length - 1;
200
+ return isLast ? `[LATEST] ${prefix}: ${m.text}` : `${prefix}: ${m.text}`;
201
+ });
202
+ parts.push(contextLines.join("\n\n"));
203
+
204
+ const userMessage: UserMessage = {
205
+ role: "user",
206
+ content: [{ type: "text", text: parts.join("\n\n") }],
207
+ timestamp: Date.now(),
208
+ };
209
+
210
+ const abort = new AbortController();
211
+ suggestionAbort = abort;
212
+
213
+ try {
214
+ const response = await complete(
215
+ model,
216
+ { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
217
+ {
218
+ apiKey: auth.apiKey,
219
+ headers: auth.headers,
220
+ signal: abort.signal,
221
+ maxTokens: 40,
222
+ onPayload: (payload: any) => {
223
+ // Disable thinking for models that support it (e.g. DeepSeek)
224
+ payload.thinking = { type: "disabled" };
225
+ return payload;
226
+ },
227
+ },
228
+ );
229
+
230
+ if (response.stopReason === "aborted") return;
231
+
232
+ const suggestion = response.content
233
+ .filter((c: any) => c.type === "text")
234
+ .map((c: any) => c.text)
235
+ .join("")
236
+ .trim()
237
+ .replace(/^["'`]|["'`]$/g, "")
238
+ .slice(0, 60);
239
+
240
+ if (suggestion && suggestion !== SKIP_TOKEN && !abort.signal.aborted) {
241
+ setHint(suggestion, "cue");
242
+ lastSuggestion = suggestion;
243
+ }
244
+ } catch {
245
+ // Suggestion is optional — fail silently
246
+ } finally {
247
+ if (suggestionAbort === abort) suggestionAbort = null;
248
+ }
249
+ }
250
+
251
+ // Track tool outcomes
252
+ pi.on("tool_execution_end", async (event: any) => {
253
+ const content =
254
+ typeof event.result === "string"
255
+ ? event.result
256
+ : Array.isArray(event.result)
257
+ ? event.result
258
+ .filter((b: any) => b.type === "text")
259
+ .map((b: any) => b.text)
260
+ .join("")
261
+ : "";
262
+ const tail =
263
+ content.length > 150 ? "..." + content.slice(-150) : content;
264
+ lastToolOutcome = {
265
+ tool: event.toolName || "unknown",
266
+ ok: !event.isError,
267
+ tail: tail.replace(/\n/g, " ").slice(0, 150),
268
+ };
269
+ });
270
+
271
+ // Track user messages → show ↩ hint + record corrections
272
+ pi.on("message_end", async (event) => {
273
+ if (event.message.role === "user") {
274
+ if (suggestionAbort) {
275
+ suggestionAbort.abort();
276
+ suggestionAbort = null;
277
+ }
278
+
279
+ const content = event.message.content;
280
+ if (!Array.isArray(content)) return;
281
+ const text = content
282
+ .filter((b: any) => b.type === "text")
283
+ .map((b: any) => b.text)
284
+ .join("");
285
+
286
+ if (text.trim() && lastSuggestion && text.trim() !== lastSuggestion) {
287
+ corrections.push({ suggested: lastSuggestion, actual: text.trim() });
288
+ if (corrections.length > MAX_CORRECTIONS) corrections.shift();
289
+ }
290
+ lastSuggestion = null;
291
+
292
+ if (text.trim()) setHint(text, "recall");
293
+ }
294
+ });
295
+
296
+ // Generate suggestion after agent completes
297
+ pi.on("agent_end", async (event: any, ctx) => {
298
+ if (event.willRetry || !event.messages?.length) return;
299
+
300
+ const lastAssistant = [...event.messages]
301
+ .reverse()
302
+ .find((m: any) => m.role === "assistant");
303
+ if (!lastAssistant) return;
304
+
305
+ const hasText = lastAssistant.content?.some(
306
+ (b: any) => b.type === "text" && b.text?.trim(),
307
+ );
308
+ if (!hasText) return;
309
+
310
+ generateSuggestion(ctx);
311
+ });
312
+
313
+ pi.on("session_start", async (event: any, ctx) => {
314
+ if (ctx.mode !== "tui") return;
315
+ widgetCtx = ctx;
316
+
317
+ if (event.reason === "reload") {
318
+ generateSuggestion(ctx);
319
+ }
320
+
321
+ // Extend editor: Tab fills hint, Enter sends it
322
+ const prevFactory = ctx.ui.getEditorComponent();
323
+
324
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
325
+ const base = prevFactory
326
+ ? prevFactory(tui, theme, keybindings)
327
+ : new CustomEditor(tui, theme, keybindings);
328
+
329
+ const originalHandleInput = base.handleInput.bind(base);
330
+
331
+ base.handleInput = (data: string) => {
332
+ const text = base.getText();
333
+ const isEmpty = !text || text.trim() === "";
334
+
335
+ if (isEmpty && currentHint) {
336
+ if (matchesKey(data, fillKey)) {
337
+ base.setText(currentHint);
338
+ return;
339
+ }
340
+ if (matchesKey(data, sendKey)) {
341
+ const hint = currentHint;
342
+ setHint(null, null);
343
+ pi.sendUserMessage(hint);
344
+ return;
345
+ }
346
+ }
347
+
348
+ originalHandleInput(data);
349
+ };
350
+
351
+ return base;
352
+ });
353
+ });
354
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "pi-next-cue",
3
+ "version": "1.0.0",
4
+ "description": "Predicts your next prompt after each agent turn — shown as a hint above the editor",
5
+ "keywords": ["pi", "pi-package", "pi-extension"],
6
+ "license": "MIT",
7
+ "author": "ouzhenkun",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/ouzhenkun/pi-next-cue.git"
11
+ },
12
+ "homepage": "https://github.com/ouzhenkun/pi-next-cue",
13
+ "bugs": {
14
+ "url": "https://github.com/ouzhenkun/pi-next-cue/issues"
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "*"
18
+ },
19
+ "pi": {
20
+ "extensions": ["./index.ts"]
21
+ }
22
+ }