pi-mono-btw 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/LICENCE.md +7 -0
- package/README.md +24 -0
- package/index.ts +499 -0
- package/package.json +20 -0
package/LICENCE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Emanuel Casco
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# btw extension
|
|
2
|
+
|
|
3
|
+
This extension adds Claude Code-style ` /btw ` behavior to pi.
|
|
4
|
+
|
|
5
|
+
## Behavior
|
|
6
|
+
|
|
7
|
+
- intercepts `/btw <question>` through the input pipeline instead of a normal extension command
|
|
8
|
+
- starts a separate model request immediately
|
|
9
|
+
- does not queue the question into the main agent loop
|
|
10
|
+
- does not interrupt the current task
|
|
11
|
+
- renders answers in a passive widget below the editor while pi keeps working
|
|
12
|
+
- stores hidden history as custom session entries (`btw-history`)
|
|
13
|
+
|
|
14
|
+
## Why it is implemented this way
|
|
15
|
+
|
|
16
|
+
In pi, normal extension commands are checked before input expansion and are not the same as prompt templates or skills. To make `/btw` work while pi is already busy, this extension handles raw input that starts with `/btw` and launches its own background completion.
|
|
17
|
+
|
|
18
|
+
## Extra shortcut
|
|
19
|
+
|
|
20
|
+
- `Ctrl+Shift+B` asks the current editor text as a side question
|
|
21
|
+
|
|
22
|
+
## Files
|
|
23
|
+
|
|
24
|
+
- `index.ts` — extension entry point
|
package/index.ts
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { complete, type UserMessage } from "@mariozechner/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
4
|
+
|
|
5
|
+
const BTW_ENTRY_TYPE = "btw-history";
|
|
6
|
+
const BTW_WIDGET_ID = "btw-widget";
|
|
7
|
+
const COMPLETED_ITEM_TTL_MS = 90_000;
|
|
8
|
+
const MAX_TRANSCRIPT_CHARS = 14_000;
|
|
9
|
+
const MAX_TOOL_RESULT_CHARS = 800;
|
|
10
|
+
const MAX_RENDER_ITEMS = 2;
|
|
11
|
+
const MAX_RENDERED_ANSWER_LINES = 6;
|
|
12
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
13
|
+
|
|
14
|
+
const SIDE_QUESTION_SYSTEM_PROMPT = [
|
|
15
|
+
"You are answering a quick side question while the user's main pi session continues working.",
|
|
16
|
+
"Use the provided session transcript only as background context.",
|
|
17
|
+
"Answer directly and concisely.",
|
|
18
|
+
"Prefer compact bullets or short paragraphs.",
|
|
19
|
+
"If the transcript is insufficient, say that briefly instead of guessing.",
|
|
20
|
+
].join("\n");
|
|
21
|
+
|
|
22
|
+
type TextBlock = { type?: string; text?: string };
|
|
23
|
+
type ToolCallBlock = { type?: string; name?: string; arguments?: Record<string, unknown> };
|
|
24
|
+
|
|
25
|
+
type SessionEntryLike = {
|
|
26
|
+
type: string;
|
|
27
|
+
message?: {
|
|
28
|
+
role?: string;
|
|
29
|
+
content?: unknown;
|
|
30
|
+
toolName?: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type BtwRecord = {
|
|
35
|
+
question: string;
|
|
36
|
+
answer?: string;
|
|
37
|
+
error?: string;
|
|
38
|
+
askedAt: string;
|
|
39
|
+
answeredAt: string;
|
|
40
|
+
model: string;
|
|
41
|
+
sessionFile?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type BtwItem = {
|
|
45
|
+
id: string;
|
|
46
|
+
question: string;
|
|
47
|
+
state: "loading" | "answer" | "error";
|
|
48
|
+
askedAt: string;
|
|
49
|
+
answeredAt?: string;
|
|
50
|
+
answer?: string;
|
|
51
|
+
error?: string;
|
|
52
|
+
model: string;
|
|
53
|
+
expiresAt?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type BtwRuntime = {
|
|
57
|
+
sessionKey: string;
|
|
58
|
+
items: BtwItem[];
|
|
59
|
+
spinnerFrame: number;
|
|
60
|
+
requestRender?: () => void;
|
|
61
|
+
spinnerTimer?: ReturnType<typeof setInterval>;
|
|
62
|
+
expiryTimer?: ReturnType<typeof setTimeout>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const runtimes = new Map<string, BtwRuntime>();
|
|
66
|
+
const pendingPersistence = new Map<string, BtwRecord[]>();
|
|
67
|
+
let nextItemId = 1;
|
|
68
|
+
|
|
69
|
+
function getSessionKey(ctx: ExtensionContext): string {
|
|
70
|
+
return ctx.sessionManager.getSessionFile() ?? `memory:${ctx.sessionManager.getSessionId()}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getRuntime(ctx: ExtensionContext): BtwRuntime {
|
|
74
|
+
const sessionKey = getSessionKey(ctx);
|
|
75
|
+
let runtime = runtimes.get(sessionKey);
|
|
76
|
+
if (!runtime) {
|
|
77
|
+
runtime = {
|
|
78
|
+
sessionKey,
|
|
79
|
+
items: [],
|
|
80
|
+
spinnerFrame: 0,
|
|
81
|
+
};
|
|
82
|
+
runtimes.set(sessionKey, runtime);
|
|
83
|
+
}
|
|
84
|
+
return runtime;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractTextParts(content: unknown): string[] {
|
|
88
|
+
if (typeof content === "string") {
|
|
89
|
+
return [content];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!Array.isArray(content)) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const textParts: string[] = [];
|
|
97
|
+
for (const part of content) {
|
|
98
|
+
if (!part || typeof part !== "object") continue;
|
|
99
|
+
const block = part as TextBlock;
|
|
100
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
101
|
+
textParts.push(block.text);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return textParts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractToolCalls(content: unknown): string[] {
|
|
108
|
+
if (!Array.isArray(content)) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const toolCalls: string[] = [];
|
|
113
|
+
for (const part of content) {
|
|
114
|
+
if (!part || typeof part !== "object") continue;
|
|
115
|
+
const block = part as ToolCallBlock;
|
|
116
|
+
if (block.type !== "toolCall" || typeof block.name !== "string") continue;
|
|
117
|
+
toolCalls.push(`Assistant called tool ${block.name} with ${JSON.stringify(block.arguments ?? {})}`);
|
|
118
|
+
}
|
|
119
|
+
return toolCalls;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function clip(text: string, maxChars: number): string {
|
|
123
|
+
if (text.length <= maxChars) return text;
|
|
124
|
+
return `${text.slice(0, maxChars)}\n...[truncated]`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildTranscriptText(entries: SessionEntryLike[]): string {
|
|
128
|
+
const relevantEntries = entries.filter((entry) => entry.type === "message").slice(-20);
|
|
129
|
+
const sections: string[] = [];
|
|
130
|
+
|
|
131
|
+
for (const entry of relevantEntries) {
|
|
132
|
+
const message = entry.message;
|
|
133
|
+
if (!message?.role) continue;
|
|
134
|
+
|
|
135
|
+
const role = message.role;
|
|
136
|
+
const text = extractTextParts(message.content).join("\n").trim();
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
|
|
139
|
+
switch (role) {
|
|
140
|
+
case "user":
|
|
141
|
+
if (text) lines.push(`User: ${text}`);
|
|
142
|
+
break;
|
|
143
|
+
case "assistant":
|
|
144
|
+
if (text) lines.push(`Assistant: ${text}`);
|
|
145
|
+
lines.push(...extractToolCalls(message.content));
|
|
146
|
+
break;
|
|
147
|
+
case "toolResult":
|
|
148
|
+
if (text) {
|
|
149
|
+
const toolName = message.toolName ?? "tool";
|
|
150
|
+
lines.push(`Tool result from ${toolName}: ${clip(text, MAX_TOOL_RESULT_CHARS)}`);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
case "bashExecution":
|
|
154
|
+
if (text) lines.push(`User bash output: ${clip(text, MAX_TOOL_RESULT_CHARS)}`);
|
|
155
|
+
break;
|
|
156
|
+
case "custom":
|
|
157
|
+
if (text) lines.push(`Extension message: ${text}`);
|
|
158
|
+
break;
|
|
159
|
+
case "branchSummary":
|
|
160
|
+
case "compactionSummary":
|
|
161
|
+
if (text) lines.push(`Summary: ${text}`);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (lines.length > 0) {
|
|
166
|
+
sections.push(lines.join("\n"));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const transcript = sections.join("\n\n");
|
|
171
|
+
if (transcript.length <= MAX_TRANSCRIPT_CHARS) return transcript;
|
|
172
|
+
return `...[earlier session context omitted]\n\n${transcript.slice(-MAX_TRANSCRIPT_CHARS)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildSideQuestionPrompt(question: string, transcript: string): string {
|
|
176
|
+
return [
|
|
177
|
+
"Current pi session transcript:",
|
|
178
|
+
"<session>",
|
|
179
|
+
transcript || "(No useful session transcript found.)",
|
|
180
|
+
"</session>",
|
|
181
|
+
"",
|
|
182
|
+
"Side question:",
|
|
183
|
+
"<question>",
|
|
184
|
+
question,
|
|
185
|
+
"</question>",
|
|
186
|
+
].join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getModelLabel(ctx: ExtensionContext): string {
|
|
190
|
+
if (!ctx.model) return "unknown-model";
|
|
191
|
+
return `${ctx.model.provider}/${ctx.model.id}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function askSideQuestion(question: string, ctx: ExtensionContext): Promise<string> {
|
|
195
|
+
if (!ctx.model) {
|
|
196
|
+
throw new Error("No model selected.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
200
|
+
if (!auth.ok) {
|
|
201
|
+
throw new Error(auth.error);
|
|
202
|
+
}
|
|
203
|
+
if (!auth.apiKey) {
|
|
204
|
+
throw new Error(`No API key available for ${getModelLabel(ctx)}.`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const transcript = buildTranscriptText(ctx.sessionManager.getBranch() as SessionEntryLike[]);
|
|
208
|
+
const userMessage: UserMessage = {
|
|
209
|
+
role: "user",
|
|
210
|
+
content: [{ type: "text", text: buildSideQuestionPrompt(question, transcript) }],
|
|
211
|
+
timestamp: Date.now(),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const response = await complete(
|
|
215
|
+
ctx.model,
|
|
216
|
+
{
|
|
217
|
+
systemPrompt: SIDE_QUESTION_SYSTEM_PROMPT,
|
|
218
|
+
messages: [userMessage],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
apiKey: auth.apiKey,
|
|
222
|
+
headers: auth.headers,
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (response.stopReason === "aborted") {
|
|
227
|
+
throw new Error("Cancelled.");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const answer = response.content
|
|
231
|
+
.filter((item): item is { type: "text"; text: string } => item.type === "text")
|
|
232
|
+
.map((item) => item.text)
|
|
233
|
+
.join("\n")
|
|
234
|
+
.trim();
|
|
235
|
+
|
|
236
|
+
return answer || "No response received.";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ensureWidget(ctx: ExtensionContext, runtime: BtwRuntime) {
|
|
240
|
+
if (!ctx.hasUI) return;
|
|
241
|
+
|
|
242
|
+
ctx.ui.setWidget(
|
|
243
|
+
BTW_WIDGET_ID,
|
|
244
|
+
(tui, theme) => {
|
|
245
|
+
runtime.requestRender = () => tui.requestRender();
|
|
246
|
+
return new BtwWidget(theme, runtime);
|
|
247
|
+
},
|
|
248
|
+
{ placement: "belowEditor" },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function persistOrQueue(pi: ExtensionAPI, ctx: ExtensionContext, record: BtwRecord) {
|
|
253
|
+
if (ctx.isIdle()) {
|
|
254
|
+
pi.appendEntry(BTW_ENTRY_TYPE, record);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const key = getSessionKey(ctx);
|
|
259
|
+
const queue = pendingPersistence.get(key) ?? [];
|
|
260
|
+
queue.push(record);
|
|
261
|
+
pendingPersistence.set(key, queue);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function flushPendingForCurrentSession(pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
265
|
+
if (!ctx.isIdle()) return;
|
|
266
|
+
|
|
267
|
+
const key = getSessionKey(ctx);
|
|
268
|
+
const queue = pendingPersistence.get(key);
|
|
269
|
+
if (!queue || queue.length === 0) return;
|
|
270
|
+
|
|
271
|
+
for (const record of queue) {
|
|
272
|
+
pi.appendEntry(BTW_ENTRY_TYPE, record);
|
|
273
|
+
}
|
|
274
|
+
pendingPersistence.delete(key);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function cleanupExpiredItems(runtime: BtwRuntime) {
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
runtime.items = runtime.items.filter((item) => item.state === "loading" || !item.expiresAt || item.expiresAt > now);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function syncRuntimeTimers(runtime: BtwRuntime) {
|
|
283
|
+
cleanupExpiredItems(runtime);
|
|
284
|
+
|
|
285
|
+
const hasLoading = runtime.items.some((item) => item.state === "loading");
|
|
286
|
+
if (hasLoading && !runtime.spinnerTimer) {
|
|
287
|
+
runtime.spinnerTimer = setInterval(() => {
|
|
288
|
+
runtime.spinnerFrame = (runtime.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
289
|
+
runtime.requestRender?.();
|
|
290
|
+
}, 120);
|
|
291
|
+
}
|
|
292
|
+
if (!hasLoading && runtime.spinnerTimer) {
|
|
293
|
+
clearInterval(runtime.spinnerTimer);
|
|
294
|
+
runtime.spinnerTimer = undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (runtime.expiryTimer) {
|
|
298
|
+
clearTimeout(runtime.expiryTimer);
|
|
299
|
+
runtime.expiryTimer = undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
const nextExpiry = runtime.items
|
|
304
|
+
.filter((item) => item.expiresAt && item.expiresAt > now)
|
|
305
|
+
.map((item) => item.expiresAt as number)
|
|
306
|
+
.sort((a, b) => a - b)[0];
|
|
307
|
+
|
|
308
|
+
if (nextExpiry) {
|
|
309
|
+
runtime.expiryTimer = setTimeout(() => {
|
|
310
|
+
cleanupExpiredItems(runtime);
|
|
311
|
+
syncRuntimeTimers(runtime);
|
|
312
|
+
runtime.requestRender?.();
|
|
313
|
+
}, Math.max(0, nextExpiry - now));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function startBtw(question: string, pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
318
|
+
if (!ctx.model) {
|
|
319
|
+
ctx.ui.notify("No model selected", "error");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const runtime = getRuntime(ctx);
|
|
324
|
+
ensureWidget(ctx, runtime);
|
|
325
|
+
|
|
326
|
+
const item: BtwItem = {
|
|
327
|
+
id: `btw-${nextItemId++}`,
|
|
328
|
+
question,
|
|
329
|
+
state: "loading",
|
|
330
|
+
askedAt: new Date().toISOString(),
|
|
331
|
+
model: getModelLabel(ctx),
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
runtime.items.unshift(item);
|
|
335
|
+
runtime.items = runtime.items.slice(0, 6);
|
|
336
|
+
syncRuntimeTimers(runtime);
|
|
337
|
+
runtime.requestRender?.();
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const answer = await askSideQuestion(question, ctx);
|
|
341
|
+
item.state = "answer";
|
|
342
|
+
item.answer = answer;
|
|
343
|
+
item.answeredAt = new Date().toISOString();
|
|
344
|
+
item.expiresAt = Date.now() + COMPLETED_ITEM_TTL_MS;
|
|
345
|
+
persistOrQueue(pi, ctx, {
|
|
346
|
+
question,
|
|
347
|
+
answer,
|
|
348
|
+
askedAt: item.askedAt,
|
|
349
|
+
answeredAt: item.answeredAt,
|
|
350
|
+
model: item.model,
|
|
351
|
+
sessionFile: ctx.sessionManager.getSessionFile(),
|
|
352
|
+
});
|
|
353
|
+
} catch (error) {
|
|
354
|
+
item.state = "error";
|
|
355
|
+
item.error = error instanceof Error ? error.message : String(error);
|
|
356
|
+
item.answeredAt = new Date().toISOString();
|
|
357
|
+
item.expiresAt = Date.now() + COMPLETED_ITEM_TTL_MS;
|
|
358
|
+
persistOrQueue(pi, ctx, {
|
|
359
|
+
question,
|
|
360
|
+
error: item.error,
|
|
361
|
+
askedAt: item.askedAt,
|
|
362
|
+
answeredAt: item.answeredAt,
|
|
363
|
+
model: item.model,
|
|
364
|
+
sessionFile: ctx.sessionManager.getSessionFile(),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
syncRuntimeTimers(runtime);
|
|
369
|
+
runtime.requestRender?.();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function normalizeShortcutQuestion(text: string): string {
|
|
373
|
+
const trimmed = text.trim();
|
|
374
|
+
if (!trimmed) return "";
|
|
375
|
+
return trimmed.replace(/^\/btw\b/i, "").trim();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function extractBtwQuestion(text: string): string | null {
|
|
379
|
+
const match = text.match(/^\/btw\b([\s\S]*)$/i);
|
|
380
|
+
if (!match) return null;
|
|
381
|
+
return match[1]?.trim() ?? "";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
class BtwWidget {
|
|
385
|
+
constructor(
|
|
386
|
+
private readonly theme: Theme,
|
|
387
|
+
private readonly runtime: BtwRuntime,
|
|
388
|
+
) {}
|
|
389
|
+
|
|
390
|
+
render(width: number): string[] {
|
|
391
|
+
cleanupExpiredItems(this.runtime);
|
|
392
|
+
if (this.runtime.items.length === 0) {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const innerWidth = Math.max(24, width);
|
|
397
|
+
const lines: string[] = [];
|
|
398
|
+
const activeCount = this.runtime.items.filter((item) => item.state === "loading").length;
|
|
399
|
+
const recentCount = this.runtime.items.filter((item) => item.state !== "loading").length;
|
|
400
|
+
const summaryParts = [activeCount > 0 ? `${activeCount} running` : undefined, recentCount > 0 ? `${recentCount} recent` : undefined]
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.join(" · ");
|
|
403
|
+
|
|
404
|
+
lines.push(this.theme.fg("accent", "BTW") + (summaryParts ? this.theme.fg("dim", ` · ${summaryParts}`) : ""));
|
|
405
|
+
lines.push(this.theme.fg("borderMuted", "─".repeat(Math.max(1, innerWidth - 2))));
|
|
406
|
+
|
|
407
|
+
for (const item of this.runtime.items.slice(0, MAX_RENDER_ITEMS)) {
|
|
408
|
+
const questionLines = wrapTextWithAnsi(this.theme.fg("accent", `Q: ${item.question}`), innerWidth);
|
|
409
|
+
lines.push(...questionLines);
|
|
410
|
+
|
|
411
|
+
if (item.state === "loading") {
|
|
412
|
+
const frame = SPINNER_FRAMES[this.runtime.spinnerFrame] ?? SPINNER_FRAMES[0]!;
|
|
413
|
+
lines.push(this.theme.fg("warning", `${frame} Answering with ${item.model}...`));
|
|
414
|
+
} else {
|
|
415
|
+
const body = item.state === "error" ? this.theme.fg("error", item.error ?? "Unknown error") : item.answer ?? "";
|
|
416
|
+
const wrapped = body
|
|
417
|
+
.split("\n")
|
|
418
|
+
.flatMap((line) => wrapTextWithAnsi(line.length > 0 ? line : " ", innerWidth));
|
|
419
|
+
const clipped = wrapped.slice(0, MAX_RENDERED_ANSWER_LINES);
|
|
420
|
+
lines.push(...clipped);
|
|
421
|
+
if (wrapped.length > clipped.length) {
|
|
422
|
+
lines.push(this.theme.fg("dim", `... ${wrapped.length - clipped.length} more line(s)`));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
lines.push("");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (lines[lines.length - 1] === "") {
|
|
430
|
+
lines.pop();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
lines.push(this.theme.fg("dim", "Use /btw <question> anytime, even while pi is still working."));
|
|
434
|
+
return lines.map((line) => truncateToWidth(line, width, "...", true));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
invalidate(): void {}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export default function (pi: ExtensionAPI) {
|
|
441
|
+
const attachCurrentSessionWidget = (_event: unknown, ctx: ExtensionContext) => {
|
|
442
|
+
ensureWidget(ctx, getRuntime(ctx));
|
|
443
|
+
flushPendingForCurrentSession(pi, ctx);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const flush = (_event: unknown, ctx: ExtensionContext) => {
|
|
447
|
+
flushPendingForCurrentSession(pi, ctx);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
pi.on("session_start", attachCurrentSessionWidget);
|
|
451
|
+
pi.on("session_switch", attachCurrentSessionWidget);
|
|
452
|
+
pi.on("agent_end", flush);
|
|
453
|
+
pi.on("session_before_switch", flush);
|
|
454
|
+
pi.on("session_before_fork", flush);
|
|
455
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
456
|
+
for (const runtime of runtimes.values()) {
|
|
457
|
+
if (runtime.spinnerTimer) clearInterval(runtime.spinnerTimer);
|
|
458
|
+
if (runtime.expiryTimer) clearTimeout(runtime.expiryTimer);
|
|
459
|
+
}
|
|
460
|
+
runtimes.clear();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
pi.on("input", (event, ctx) => {
|
|
464
|
+
if (!ctx.hasUI) {
|
|
465
|
+
return { action: "continue" as const };
|
|
466
|
+
}
|
|
467
|
+
if (event.source === "extension") {
|
|
468
|
+
return { action: "continue" as const };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const question = extractBtwQuestion(event.text);
|
|
472
|
+
if (question === null) {
|
|
473
|
+
return { action: "continue" as const };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!question) {
|
|
477
|
+
ctx.ui.notify("Usage: /btw <question>", "warning");
|
|
478
|
+
return { action: "handled" as const };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
void startBtw(question, pi, ctx);
|
|
482
|
+
return { action: "handled" as const };
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
pi.registerShortcut("ctrl+shift+b", {
|
|
486
|
+
description: "Ask the current editor text as a side question",
|
|
487
|
+
handler: async (ctx) => {
|
|
488
|
+
if (!ctx.hasUI) return;
|
|
489
|
+
const editorText = ctx.ui.getEditorText();
|
|
490
|
+
const question = normalizeShortcutQuestion(editorText);
|
|
491
|
+
if (!question) {
|
|
492
|
+
ctx.ui.notify("Type a question in the editor, then press Ctrl+Shift+B, or submit /btw <question>.", "warning");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
ctx.ui.setEditorText("");
|
|
496
|
+
void startBtw(question, pi, ctx);
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-mono-btw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that answers side questions while the main agent keeps running",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension"
|
|
8
|
+
],
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@mariozechner/pi-ai": "*",
|
|
11
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
12
|
+
"@mariozechner/pi-tui": "*",
|
|
13
|
+
"@sinclair/typebox": "*"
|
|
14
|
+
},
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./index.ts"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|