pi-agent-extensions 0.3.4 → 0.3.6

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/README.md CHANGED
@@ -32,6 +32,7 @@ Original repository: https://github.com/mitsuhiko/agent-stuff
32
32
  | **control** | RPC | Inter-session communication & control | ⚙️ Beta |
33
33
  | **answer** | Tool | Structured Q&A for complex queries | ⚙️ Beta |
34
34
  | **cwd_history** | Tracker | Tracks directory changes in context | ✅ Stable |
35
+ | **btw** | Command | Quick side questions without history | ✅ Stable |
35
36
  | **nvidia-nim** | Command | Nvidia NIM auth & config | ✅ Stable |
36
37
 
37
38
  ## Install
@@ -271,6 +272,36 @@ A personality engine for Pi that makes waiting fun.
271
272
 
272
273
  See [extensions/whimsical/README.md](extensions/whimsical/README.md) for details.
273
274
 
275
+ ### BTW
276
+
277
+ Ask quick "by the way" side questions without polluting your conversation history. Inspired by Claude Code's `/btw` command.
278
+
279
+ **Usage:**
280
+
281
+ ```bash
282
+ /btw what's the syntax for useEffect cleanup?
283
+ /btw which files did we modify just now?
284
+ /btw why did you choose Zustand over Redux?
285
+ ```
286
+
287
+ **Features:**
288
+ - ✅ Full conversation context visibility
289
+ - ✅ No tool access (lightweight, read-only)
290
+ - ✅ Ephemeral overlay — nothing enters session history
291
+ - ✅ Zero context cost — no tokens wasted
292
+ - ✅ Scrollable answer (↑↓/j/k, PgUp/PgDn)
293
+ - ✅ Uses your currently selected model
294
+ - ✅ Non-UI fallback (prints to stdout)
295
+
296
+ **When to use `/btw` vs normal prompts:**
297
+
298
+ | Use `/btw` | Use normal prompts |
299
+ |---|---|
300
+ | Quick syntax checks | Requests needing tool access |
301
+ | Confirming earlier decisions | Changes you want tracked |
302
+ | Recalling context details | Complex multi-step tasks |
303
+ | "One and done" lookups | Follow-up conversations |
304
+
274
305
  ### Productivity Tools
275
306
 
276
307
  **Files (`/files`)**
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Testable logic for the /btw extension.
3
+ *
4
+ * Separated from index.ts so pure functions can be unit-tested
5
+ * without importing pi-coding-agent or pi-tui.
6
+ */
7
+
8
+ /**
9
+ * System prompt for BTW side questions.
10
+ * Instructs the LLM to answer concisely from conversation context only.
11
+ */
12
+ export const BTW_SYSTEM_PROMPT = `You are answering a quick "by the way" side question during a coding session.
13
+
14
+ Rules:
15
+ - Answer concisely and directly based on the conversation context provided.
16
+ - You have NO tool access — you cannot read files, run commands, or make changes.
17
+ - Only answer based on information already present in the conversation.
18
+ - Keep your response brief and to the point.
19
+ - Use markdown formatting where helpful (code blocks, lists, bold).
20
+ - If the conversation context doesn't contain enough information to answer, say so honestly.`;
21
+
22
+ /**
23
+ * Build the user message for the BTW LLM call.
24
+ * Includes the serialized conversation as context and the user's question.
25
+ */
26
+ export function buildBtwUserMessage(
27
+ conversationText: string,
28
+ question: string,
29
+ ): string {
30
+ return `<conversation_context>
31
+ ${conversationText}
32
+ </conversation_context>
33
+
34
+ <side_question>
35
+ ${question}
36
+ </side_question>
37
+
38
+ Answer the side question above based on the conversation context. Be concise.`;
39
+ }
40
+
41
+ /**
42
+ * Validate the /btw command arguments.
43
+ * Returns the question text or an error message.
44
+ */
45
+ export function validateBtwArgs(args: string | undefined): {
46
+ valid: boolean;
47
+ question?: string;
48
+ error?: string;
49
+ } {
50
+ const question = args?.trim();
51
+ if (!question || question.length === 0) {
52
+ return {
53
+ valid: false,
54
+ error: "Usage: /btw <question> — Ask a quick side question without polluting conversation history.",
55
+ };
56
+ }
57
+ return { valid: true, question };
58
+ }
59
+
60
+ /**
61
+ * Extract text content from an LLM response content array.
62
+ */
63
+ export function extractResponseText(
64
+ content: Array<{ type: string; text?: string }>,
65
+ ): string {
66
+ return content
67
+ .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
68
+ .map((c) => c.text)
69
+ .join("\n");
70
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * BTW Extension
3
+ *
4
+ * Provides a `/btw` command for quick side questions that don't pollute
5
+ * the conversation history. The answer appears in a temporary overlay
6
+ * and is fully ephemeral — nothing is persisted to the session.
7
+ *
8
+ * Usage:
9
+ * /btw what's the syntax for useEffect cleanup?
10
+ * /btw which files did we modify?
11
+ * /btw why did you choose that approach?
12
+ *
13
+ * Key behaviors:
14
+ * - Full visibility into current conversation context
15
+ * - No tool access (lightweight, read-only)
16
+ * - Answer displayed in dismissable overlay
17
+ * - Zero context cost — no tokens wasted on history
18
+ */
19
+
20
+ import { complete, type UserMessage } from "@mariozechner/pi-ai";
21
+ import type {
22
+ ExtensionAPI,
23
+ ExtensionCommandContext,
24
+ } from "@mariozechner/pi-coding-agent";
25
+ import {
26
+ BorderedLoader,
27
+ convertToLlm,
28
+ serializeConversation,
29
+ } from "@mariozechner/pi-coding-agent";
30
+ import {
31
+ type Component,
32
+ Container,
33
+ Key,
34
+ matchesKey,
35
+ Text,
36
+ wrapTextWithAnsi,
37
+ visibleWidth,
38
+ type TUI,
39
+ } from "@mariozechner/pi-tui";
40
+
41
+ import { getRequestAuth } from "../shared/auth.js";
42
+ import {
43
+ BTW_SYSTEM_PROMPT,
44
+ buildBtwUserMessage,
45
+ validateBtwArgs,
46
+ extractResponseText,
47
+ } from "./btw.js";
48
+
49
+
50
+ /**
51
+ * Overlay component that displays the BTW answer.
52
+ * Supports scrolling for long answers.
53
+ */
54
+ class BtwOverlay implements Component {
55
+ private tui: TUI;
56
+ private theme: any;
57
+ private question: string;
58
+ private answer: string;
59
+ private onDone: () => void;
60
+ private scrollOffset = 0;
61
+ private cachedWidth?: number;
62
+ private cachedLines?: string[];
63
+
64
+ constructor(
65
+ tui: TUI,
66
+ theme: any,
67
+ question: string,
68
+ answer: string,
69
+ onDone: () => void,
70
+ ) {
71
+ this.tui = tui;
72
+ this.theme = theme;
73
+ this.question = question;
74
+ this.answer = answer;
75
+ this.onDone = onDone;
76
+ }
77
+
78
+ handleInput(data: string): void {
79
+ // Dismiss overlay
80
+ if (
81
+ matchesKey(data, Key.escape) ||
82
+ matchesKey(data, Key.ctrl("c")) ||
83
+ data === " " ||
84
+ data.toLowerCase() === "q"
85
+ ) {
86
+ this.onDone();
87
+ return;
88
+ }
89
+
90
+ // Scroll
91
+ if (matchesKey(data, Key.up) || data === "k") {
92
+ if (this.scrollOffset > 0) {
93
+ this.scrollOffset--;
94
+ this.invalidate();
95
+ this.tui.requestRender();
96
+ }
97
+ return;
98
+ }
99
+ if (matchesKey(data, Key.down) || data === "j") {
100
+ this.scrollOffset++;
101
+ this.invalidate();
102
+ this.tui.requestRender();
103
+ return;
104
+ }
105
+
106
+ // Page up/down
107
+ if (matchesKey(data, Key.pageUp)) {
108
+ this.scrollOffset = Math.max(0, this.scrollOffset - 10);
109
+ this.invalidate();
110
+ this.tui.requestRender();
111
+ return;
112
+ }
113
+ if (matchesKey(data, Key.pageDown)) {
114
+ this.scrollOffset += 10;
115
+ this.invalidate();
116
+ this.tui.requestRender();
117
+ return;
118
+ }
119
+ }
120
+
121
+ invalidate(): void {
122
+ this.cachedWidth = undefined;
123
+ this.cachedLines = undefined;
124
+ }
125
+
126
+ render(width: number): string[] {
127
+ if (this.cachedLines && this.cachedWidth === width) {
128
+ return this.cachedLines;
129
+ }
130
+
131
+ const theme = this.theme;
132
+ const boxWidth = Math.min(width - 2, 120);
133
+ const contentWidth = boxWidth - 6; // padding on each side
134
+
135
+ const horizontalLine = (count: number) => "─".repeat(count);
136
+
137
+ const boxLine = (content: string, leftPad: number = 2): string => {
138
+ const paddedContent = " ".repeat(leftPad) + content;
139
+ const contentLen = visibleWidth(paddedContent);
140
+ const rightPad = Math.max(0, boxWidth - contentLen - 2);
141
+ return theme.fg("border", "│") + paddedContent + " ".repeat(rightPad) + theme.fg("border", "│");
142
+ };
143
+
144
+ const emptyBoxLine = (): string => {
145
+ return theme.fg("border", "│") + " ".repeat(boxWidth - 2) + theme.fg("border", "│");
146
+ };
147
+
148
+ const padToWidth = (line: string): string => {
149
+ const len = visibleWidth(line);
150
+ return line + " ".repeat(Math.max(0, width - len));
151
+ };
152
+
153
+ const lines: string[] = [];
154
+
155
+ // Top border
156
+ lines.push(padToWidth(theme.fg("accent", "╭" + horizontalLine(boxWidth - 2) + "╮")));
157
+
158
+ // Title
159
+ const title = theme.fg("accent", theme.bold("btw"));
160
+ lines.push(padToWidth(boxLine(title)));
161
+
162
+ // Separator
163
+ lines.push(padToWidth(theme.fg("accent", "├" + horizontalLine(boxWidth - 2) + "┤")));
164
+
165
+ // Question
166
+ const questionLabel = theme.fg("muted", "Q: ") + theme.fg("text", this.question);
167
+ const wrappedQuestion = wrapTextWithAnsi(questionLabel, contentWidth);
168
+ for (const line of wrappedQuestion) {
169
+ lines.push(padToWidth(boxLine(line)));
170
+ }
171
+
172
+ lines.push(padToWidth(emptyBoxLine()));
173
+
174
+ // Separator between question and answer
175
+ lines.push(padToWidth(theme.fg("border", "├" + horizontalLine(boxWidth - 2) + "┤")));
176
+ lines.push(padToWidth(emptyBoxLine()));
177
+
178
+ // Answer — wrap and apply scroll
179
+ const answerLines: string[] = [];
180
+ for (const paragraph of this.answer.split("\n")) {
181
+ if (paragraph.trim() === "") {
182
+ answerLines.push("");
183
+ } else {
184
+ const wrapped = wrapTextWithAnsi(paragraph, contentWidth);
185
+ answerLines.push(...wrapped);
186
+ }
187
+ }
188
+
189
+ // Clamp scroll offset
190
+ const termHeight = this.tui.height ?? 24;
191
+ const headerLines = lines.length;
192
+ const footerLines = 3; // separator + hint + bottom border
193
+ const maxVisibleAnswerLines = Math.max(1, termHeight - headerLines - footerLines - 2);
194
+
195
+ if (this.scrollOffset > Math.max(0, answerLines.length - maxVisibleAnswerLines)) {
196
+ this.scrollOffset = Math.max(0, answerLines.length - maxVisibleAnswerLines);
197
+ }
198
+
199
+ const visibleAnswerLines = answerLines.slice(
200
+ this.scrollOffset,
201
+ this.scrollOffset + maxVisibleAnswerLines,
202
+ );
203
+
204
+ for (const line of visibleAnswerLines) {
205
+ lines.push(padToWidth(boxLine(line)));
206
+ }
207
+
208
+ // Scroll indicator
209
+ if (answerLines.length > maxVisibleAnswerLines) {
210
+ const scrollInfo = theme.fg("dim", `[${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxVisibleAnswerLines, answerLines.length)}/${answerLines.length}]`);
211
+ lines.push(padToWidth(boxLine(scrollInfo)));
212
+ }
213
+
214
+ lines.push(padToWidth(emptyBoxLine()));
215
+
216
+ // Footer
217
+ lines.push(padToWidth(theme.fg("accent", "├" + horizontalLine(boxWidth - 2) + "┤")));
218
+ const hint = theme.fg("dim", "Esc/Space/q to dismiss · ↑↓/j/k scroll · PgUp/PgDn");
219
+ lines.push(padToWidth(boxLine(hint)));
220
+ lines.push(padToWidth(theme.fg("accent", "╰" + horizontalLine(boxWidth - 2) + "╯")));
221
+
222
+ this.cachedWidth = width;
223
+ this.cachedLines = lines;
224
+ return lines;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Main /btw command handler
230
+ */
231
+ async function runBtwCommand(
232
+ args: string | undefined,
233
+ ctx: ExtensionCommandContext,
234
+ ): Promise<void> {
235
+ // Validate args
236
+ const validation = validateBtwArgs(args);
237
+ if (!validation.valid) {
238
+ if (ctx.hasUI) {
239
+ ctx.ui.notify(validation.error!, "error");
240
+ } else {
241
+ console.error(validation.error);
242
+ }
243
+ return;
244
+ }
245
+ const question = validation.question!;
246
+
247
+ // Check for model
248
+ if (!ctx.model) {
249
+ const errorMsg = "No model selected. Use /model to select a model first.";
250
+ if (ctx.hasUI) {
251
+ ctx.ui.notify(errorMsg, "error");
252
+ } else {
253
+ console.error(errorMsg);
254
+ }
255
+ return;
256
+ }
257
+
258
+ // Build conversation context
259
+ const sessionContext = ctx.sessionManager.buildSessionContext();
260
+ const messages = sessionContext.messages;
261
+
262
+ let conversationText = "";
263
+ if (messages.length > 0) {
264
+ const llmMessages = convertToLlm(messages);
265
+ conversationText = serializeConversation(llmMessages);
266
+ }
267
+
268
+ // Use the currently selected model
269
+ const btwModel = ctx.model;
270
+
271
+ // Build LLM messages
272
+ const userMessage: UserMessage = {
273
+ role: "user",
274
+ content: [{ type: "text", text: buildBtwUserMessage(conversationText, question) }],
275
+ timestamp: Date.now(),
276
+ };
277
+
278
+ if (!ctx.hasUI) {
279
+ // Non-interactive mode: print answer to stdout
280
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, btwModel);
281
+ const response = await complete(
282
+ btwModel,
283
+ { systemPrompt: BTW_SYSTEM_PROMPT, messages: [userMessage] },
284
+ { ...requestAuth },
285
+ );
286
+
287
+ if (response.stopReason === "error") {
288
+ console.error(response.errorMessage ?? "LLM error");
289
+ return;
290
+ }
291
+
292
+ const answerText = extractResponseText(response.content);
293
+ console.log(`\n> btw: ${question}\n`);
294
+ console.log(answerText);
295
+ return;
296
+ }
297
+
298
+ // Interactive mode: show loader, then overlay
299
+
300
+ // Step 1: Get the answer with a loading spinner
301
+ const answerResult = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
302
+ const loader = new BorderedLoader(
303
+ tui,
304
+ theme,
305
+ `Thinking (${btwModel.id})...`,
306
+ );
307
+ loader.onAbort = () => done(null);
308
+
309
+ const doQuery = async () => {
310
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, btwModel);
311
+ const response = await complete(
312
+ btwModel,
313
+ { systemPrompt: BTW_SYSTEM_PROMPT, messages: [userMessage] },
314
+ { ...requestAuth, signal: loader.signal },
315
+ );
316
+
317
+ if (response.stopReason === "aborted") {
318
+ return null;
319
+ }
320
+
321
+ if (response.stopReason === "error") {
322
+ return null;
323
+ }
324
+
325
+ return extractResponseText(response.content);
326
+ };
327
+
328
+ doQuery()
329
+ .then(done)
330
+ .catch((err) => {
331
+ console.error("BTW query failed:", err);
332
+ done(null);
333
+ });
334
+
335
+ return loader;
336
+ });
337
+
338
+ if (answerResult === null) {
339
+ ctx.ui.notify("Cancelled", "info");
340
+ return;
341
+ }
342
+
343
+ if (answerResult.trim() === "") {
344
+ ctx.ui.notify("No answer received", "warning");
345
+ return;
346
+ }
347
+
348
+ // Step 2: Show the answer in an overlay
349
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
350
+ return new BtwOverlay(tui, theme, question, answerResult, done);
351
+ });
352
+
353
+ // Nothing persisted — fully ephemeral
354
+ }
355
+
356
+ /**
357
+ * Main extension entry point
358
+ */
359
+ export default function btwExtension(pi: ExtensionAPI) {
360
+ pi.registerCommand("btw", {
361
+ description: "Ask a quick side question without polluting conversation history",
362
+ handler: async (args, ctx) => {
363
+ await runBtwCommand(args, ctx);
364
+ },
365
+ });
366
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-extensions",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Collection of extensions for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -46,7 +46,8 @@
46
46
  "./extensions/session-breakdown/index.ts",
47
47
  "./extensions/todos/index.ts",
48
48
  "./extensions/whimsical/index.ts",
49
- "./extensions/nvidia-nim/index.ts"
49
+ "./extensions/nvidia-nim/index.ts",
50
+ "./extensions/btw/index.ts"
50
51
  ],
51
52
  "themes": [
52
53
  "./themes/nightowl.json",