pi-agent-extensions 0.3.4 → 0.3.5

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
+ - ✅ Prefers cheap/fast models (Codex mini → Haiku → current)
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,400 @@
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 Model, type Api, 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, hasRequestAuth } from "../shared/auth.js";
42
+ import {
43
+ BTW_SYSTEM_PROMPT,
44
+ buildBtwUserMessage,
45
+ validateBtwArgs,
46
+ extractResponseText,
47
+ } from "./btw.js";
48
+
49
+ const HAIKU_MODEL_ID = "claude-haiku-4-5";
50
+ const CODEX_MODEL_ID = "gpt-5.1-codex-mini";
51
+
52
+ /**
53
+ * Select a cheap/fast model for BTW side questions.
54
+ * Prefers Codex mini → Haiku → current model.
55
+ */
56
+ async function selectBtwModel(
57
+ currentModel: Model<Api>,
58
+ modelRegistry: {
59
+ find: (provider: string, modelId: string) => Model<Api> | undefined;
60
+ getApiKeyAndHeaders: (
61
+ model: Model<Api>,
62
+ ) => Promise<
63
+ | { ok: true; apiKey?: string; headers?: Record<string, string> }
64
+ | { ok: false; error: string }
65
+ >;
66
+ },
67
+ ): Promise<Model<Api>> {
68
+ // Try Codex mini first (cheapest)
69
+ const codexModel = modelRegistry.find("openai-codex", CODEX_MODEL_ID);
70
+ if (codexModel && (await hasRequestAuth(modelRegistry, codexModel))) {
71
+ return codexModel;
72
+ }
73
+
74
+ // Try Haiku (fast & cheap)
75
+ const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID);
76
+ if (haikuModel && (await hasRequestAuth(modelRegistry, haikuModel))) {
77
+ return haikuModel;
78
+ }
79
+
80
+ // Fallback to current model
81
+ return currentModel;
82
+ }
83
+
84
+ /**
85
+ * Overlay component that displays the BTW answer.
86
+ * Supports scrolling for long answers.
87
+ */
88
+ class BtwOverlay implements Component {
89
+ private tui: TUI;
90
+ private theme: any;
91
+ private question: string;
92
+ private answer: string;
93
+ private onDone: () => void;
94
+ private scrollOffset = 0;
95
+ private cachedWidth?: number;
96
+ private cachedLines?: string[];
97
+
98
+ constructor(
99
+ tui: TUI,
100
+ theme: any,
101
+ question: string,
102
+ answer: string,
103
+ onDone: () => void,
104
+ ) {
105
+ this.tui = tui;
106
+ this.theme = theme;
107
+ this.question = question;
108
+ this.answer = answer;
109
+ this.onDone = onDone;
110
+ }
111
+
112
+ handleInput(data: string): void {
113
+ // Dismiss overlay
114
+ if (
115
+ matchesKey(data, Key.escape) ||
116
+ matchesKey(data, Key.ctrl("c")) ||
117
+ data === " " ||
118
+ data.toLowerCase() === "q"
119
+ ) {
120
+ this.onDone();
121
+ return;
122
+ }
123
+
124
+ // Scroll
125
+ if (matchesKey(data, Key.up) || data === "k") {
126
+ if (this.scrollOffset > 0) {
127
+ this.scrollOffset--;
128
+ this.invalidate();
129
+ this.tui.requestRender();
130
+ }
131
+ return;
132
+ }
133
+ if (matchesKey(data, Key.down) || data === "j") {
134
+ this.scrollOffset++;
135
+ this.invalidate();
136
+ this.tui.requestRender();
137
+ return;
138
+ }
139
+
140
+ // Page up/down
141
+ if (matchesKey(data, Key.pageUp)) {
142
+ this.scrollOffset = Math.max(0, this.scrollOffset - 10);
143
+ this.invalidate();
144
+ this.tui.requestRender();
145
+ return;
146
+ }
147
+ if (matchesKey(data, Key.pageDown)) {
148
+ this.scrollOffset += 10;
149
+ this.invalidate();
150
+ this.tui.requestRender();
151
+ return;
152
+ }
153
+ }
154
+
155
+ invalidate(): void {
156
+ this.cachedWidth = undefined;
157
+ this.cachedLines = undefined;
158
+ }
159
+
160
+ render(width: number): string[] {
161
+ if (this.cachedLines && this.cachedWidth === width) {
162
+ return this.cachedLines;
163
+ }
164
+
165
+ const theme = this.theme;
166
+ const boxWidth = Math.min(width - 2, 120);
167
+ const contentWidth = boxWidth - 6; // padding on each side
168
+
169
+ const horizontalLine = (count: number) => "─".repeat(count);
170
+
171
+ const boxLine = (content: string, leftPad: number = 2): string => {
172
+ const paddedContent = " ".repeat(leftPad) + content;
173
+ const contentLen = visibleWidth(paddedContent);
174
+ const rightPad = Math.max(0, boxWidth - contentLen - 2);
175
+ return theme.fg("border", "│") + paddedContent + " ".repeat(rightPad) + theme.fg("border", "│");
176
+ };
177
+
178
+ const emptyBoxLine = (): string => {
179
+ return theme.fg("border", "│") + " ".repeat(boxWidth - 2) + theme.fg("border", "│");
180
+ };
181
+
182
+ const padToWidth = (line: string): string => {
183
+ const len = visibleWidth(line);
184
+ return line + " ".repeat(Math.max(0, width - len));
185
+ };
186
+
187
+ const lines: string[] = [];
188
+
189
+ // Top border
190
+ lines.push(padToWidth(theme.fg("accent", "╭" + horizontalLine(boxWidth - 2) + "╮")));
191
+
192
+ // Title
193
+ const title = theme.fg("accent", theme.bold("btw"));
194
+ lines.push(padToWidth(boxLine(title)));
195
+
196
+ // Separator
197
+ lines.push(padToWidth(theme.fg("accent", "├" + horizontalLine(boxWidth - 2) + "┤")));
198
+
199
+ // Question
200
+ const questionLabel = theme.fg("muted", "Q: ") + theme.fg("text", this.question);
201
+ const wrappedQuestion = wrapTextWithAnsi(questionLabel, contentWidth);
202
+ for (const line of wrappedQuestion) {
203
+ lines.push(padToWidth(boxLine(line)));
204
+ }
205
+
206
+ lines.push(padToWidth(emptyBoxLine()));
207
+
208
+ // Separator between question and answer
209
+ lines.push(padToWidth(theme.fg("border", "├" + horizontalLine(boxWidth - 2) + "┤")));
210
+ lines.push(padToWidth(emptyBoxLine()));
211
+
212
+ // Answer — wrap and apply scroll
213
+ const answerLines: string[] = [];
214
+ for (const paragraph of this.answer.split("\n")) {
215
+ if (paragraph.trim() === "") {
216
+ answerLines.push("");
217
+ } else {
218
+ const wrapped = wrapTextWithAnsi(paragraph, contentWidth);
219
+ answerLines.push(...wrapped);
220
+ }
221
+ }
222
+
223
+ // Clamp scroll offset
224
+ const termHeight = this.tui.height ?? 24;
225
+ const headerLines = lines.length;
226
+ const footerLines = 3; // separator + hint + bottom border
227
+ const maxVisibleAnswerLines = Math.max(1, termHeight - headerLines - footerLines - 2);
228
+
229
+ if (this.scrollOffset > Math.max(0, answerLines.length - maxVisibleAnswerLines)) {
230
+ this.scrollOffset = Math.max(0, answerLines.length - maxVisibleAnswerLines);
231
+ }
232
+
233
+ const visibleAnswerLines = answerLines.slice(
234
+ this.scrollOffset,
235
+ this.scrollOffset + maxVisibleAnswerLines,
236
+ );
237
+
238
+ for (const line of visibleAnswerLines) {
239
+ lines.push(padToWidth(boxLine(line)));
240
+ }
241
+
242
+ // Scroll indicator
243
+ if (answerLines.length > maxVisibleAnswerLines) {
244
+ const scrollInfo = theme.fg("dim", `[${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxVisibleAnswerLines, answerLines.length)}/${answerLines.length}]`);
245
+ lines.push(padToWidth(boxLine(scrollInfo)));
246
+ }
247
+
248
+ lines.push(padToWidth(emptyBoxLine()));
249
+
250
+ // Footer
251
+ lines.push(padToWidth(theme.fg("accent", "├" + horizontalLine(boxWidth - 2) + "┤")));
252
+ const hint = theme.fg("dim", "Esc/Space/q to dismiss · ↑↓/j/k scroll · PgUp/PgDn");
253
+ lines.push(padToWidth(boxLine(hint)));
254
+ lines.push(padToWidth(theme.fg("accent", "╰" + horizontalLine(boxWidth - 2) + "╯")));
255
+
256
+ this.cachedWidth = width;
257
+ this.cachedLines = lines;
258
+ return lines;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Main /btw command handler
264
+ */
265
+ async function runBtwCommand(
266
+ args: string | undefined,
267
+ ctx: ExtensionCommandContext,
268
+ ): Promise<void> {
269
+ // Validate args
270
+ const validation = validateBtwArgs(args);
271
+ if (!validation.valid) {
272
+ if (ctx.hasUI) {
273
+ ctx.ui.notify(validation.error!, "error");
274
+ } else {
275
+ console.error(validation.error);
276
+ }
277
+ return;
278
+ }
279
+ const question = validation.question!;
280
+
281
+ // Check for model
282
+ if (!ctx.model) {
283
+ const errorMsg = "No model selected. Use /model to select a model first.";
284
+ if (ctx.hasUI) {
285
+ ctx.ui.notify(errorMsg, "error");
286
+ } else {
287
+ console.error(errorMsg);
288
+ }
289
+ return;
290
+ }
291
+
292
+ // Build conversation context
293
+ const sessionContext = ctx.sessionManager.buildSessionContext();
294
+ const messages = sessionContext.messages;
295
+
296
+ let conversationText = "";
297
+ if (messages.length > 0) {
298
+ const llmMessages = convertToLlm(messages);
299
+ conversationText = serializeConversation(llmMessages);
300
+ }
301
+
302
+ // Select model (prefer cheap models)
303
+ const btwModel = await selectBtwModel(ctx.model, ctx.modelRegistry);
304
+
305
+ // Build LLM messages
306
+ const userMessage: UserMessage = {
307
+ role: "user",
308
+ content: [{ type: "text", text: buildBtwUserMessage(conversationText, question) }],
309
+ timestamp: Date.now(),
310
+ };
311
+
312
+ if (!ctx.hasUI) {
313
+ // Non-interactive mode: print answer to stdout
314
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, btwModel);
315
+ const response = await complete(
316
+ btwModel,
317
+ { systemPrompt: BTW_SYSTEM_PROMPT, messages: [userMessage] },
318
+ { ...requestAuth },
319
+ );
320
+
321
+ if (response.stopReason === "error") {
322
+ console.error(response.errorMessage ?? "LLM error");
323
+ return;
324
+ }
325
+
326
+ const answerText = extractResponseText(response.content);
327
+ console.log(`\n> btw: ${question}\n`);
328
+ console.log(answerText);
329
+ return;
330
+ }
331
+
332
+ // Interactive mode: show loader, then overlay
333
+
334
+ // Step 1: Get the answer with a loading spinner
335
+ const answerResult = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
336
+ const loader = new BorderedLoader(
337
+ tui,
338
+ theme,
339
+ `Thinking (${btwModel.id})...`,
340
+ );
341
+ loader.onAbort = () => done(null);
342
+
343
+ const doQuery = async () => {
344
+ const requestAuth = await getRequestAuth(ctx.modelRegistry, btwModel);
345
+ const response = await complete(
346
+ btwModel,
347
+ { systemPrompt: BTW_SYSTEM_PROMPT, messages: [userMessage] },
348
+ { ...requestAuth, signal: loader.signal },
349
+ );
350
+
351
+ if (response.stopReason === "aborted") {
352
+ return null;
353
+ }
354
+
355
+ if (response.stopReason === "error") {
356
+ return null;
357
+ }
358
+
359
+ return extractResponseText(response.content);
360
+ };
361
+
362
+ doQuery()
363
+ .then(done)
364
+ .catch((err) => {
365
+ console.error("BTW query failed:", err);
366
+ done(null);
367
+ });
368
+
369
+ return loader;
370
+ });
371
+
372
+ if (answerResult === null) {
373
+ ctx.ui.notify("Cancelled", "info");
374
+ return;
375
+ }
376
+
377
+ if (answerResult.trim() === "") {
378
+ ctx.ui.notify("No answer received", "warning");
379
+ return;
380
+ }
381
+
382
+ // Step 2: Show the answer in an overlay
383
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
384
+ return new BtwOverlay(tui, theme, question, answerResult, done);
385
+ });
386
+
387
+ // Nothing persisted — fully ephemeral
388
+ }
389
+
390
+ /**
391
+ * Main extension entry point
392
+ */
393
+ export default function btwExtension(pi: ExtensionAPI) {
394
+ pi.registerCommand("btw", {
395
+ description: "Ask a quick side question without polluting conversation history",
396
+ handler: async (args, ctx) => {
397
+ await runBtwCommand(args, ctx);
398
+ },
399
+ });
400
+ }
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.5",
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",