pi-btw-cc 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 +67 -0
- package/docs/btw-browse.png +0 -0
- package/docs/btw-browse.svg +24 -0
- package/docs/btw-overlay.png +0 -0
- package/docs/btw-overlay.svg +24 -0
- package/docs/btw-promote.png +0 -0
- package/docs/btw-promote.svg +16 -0
- package/package.json +73 -0
- package/src/context.ts +136 -0
- package/src/index.ts +319 -0
- package/src/overlay.ts +623 -0
- package/src/thread.ts +178 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /btw — ask a side question that inherits the main conversation but never
|
|
3
|
+
* enters it.
|
|
4
|
+
*
|
|
5
|
+
* Behavior (Claude Code compatible):
|
|
6
|
+
* - `/btw <question>` answers in a floating overlay while the main agent keeps
|
|
7
|
+
* running. The side request sees the main session context but has no tools.
|
|
8
|
+
* - Side questions are continuous within a session: earlier exchanges are
|
|
9
|
+
* replayed into the next request. The thread is persisted in the session file
|
|
10
|
+
* as `btw-thread` custom entries, so it survives reloads, restarts and
|
|
11
|
+
* `/resume` of the same session, and follows the active `/tree` branch.
|
|
12
|
+
* - Overlay keys: ↑/↓ scroll, ⇧←/→ browse the side thread, `c` copy the selected
|
|
13
|
+
* answer, `f` promote the selected exchange into the main conversation, `x`
|
|
14
|
+
* delete the selected exchange, Esc/Enter close.
|
|
15
|
+
* - `/btw` without a question reopens the overlay on the existing thread.
|
|
16
|
+
*
|
|
17
|
+
* Nothing reaches the main conversation unless `f` is pressed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
|
|
21
|
+
import {
|
|
22
|
+
convertToLlm,
|
|
23
|
+
copyToClipboard,
|
|
24
|
+
getMarkdownTheme,
|
|
25
|
+
sessionEntryToContextMessages,
|
|
26
|
+
type ExtensionAPI,
|
|
27
|
+
type ExtensionCommandContext,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { Container, Markdown, Text } from "@earendil-works/pi-tui";
|
|
30
|
+
import { BTW_SYSTEM_PROMPT, buildSideRequest } from "./context.ts";
|
|
31
|
+
import { BtwOverlay, OVERLAY_MARGIN, OVERLAY_MAX_HEIGHT_PERCENT, OVERLAY_MIN_WIDTH, OVERLAY_WIDTH_PERCENT, type BtwOverlayResult, type BtwQueryOutcome, type BtwThreadSink } from "./overlay.ts";
|
|
32
|
+
import {
|
|
33
|
+
appendExchange,
|
|
34
|
+
BTW_ENTRY_TYPE,
|
|
35
|
+
BTW_MESSAGE_TYPE,
|
|
36
|
+
findThread,
|
|
37
|
+
markPromoted,
|
|
38
|
+
removeExchange,
|
|
39
|
+
replayWindow,
|
|
40
|
+
type BtwExchange,
|
|
41
|
+
} from "./thread.ts";
|
|
42
|
+
|
|
43
|
+
export default function btwExtension(pi: ExtensionAPI): void {
|
|
44
|
+
/** Side thread of the active session, oldest first. */
|
|
45
|
+
let thread: BtwExchange[] = [];
|
|
46
|
+
/** Guards against two stacked overlays; a second one would steal focus. */
|
|
47
|
+
let overlayOpen = false;
|
|
48
|
+
/** Open overlay, so a session swap can stop it before the runtime goes stale. */
|
|
49
|
+
let activeOverlay: BtwOverlay | null = null;
|
|
50
|
+
|
|
51
|
+
pi.on("session_start", (_event, ctx) => {
|
|
52
|
+
thread = findThread(ctx.sessionManager.getBranch());
|
|
53
|
+
// Cleanup is shared with session_shutdown because the host does not promise
|
|
54
|
+
// the shutdown event before a rebind; dispose() is idempotent.
|
|
55
|
+
activeOverlay?.dispose();
|
|
56
|
+
overlayOpen = false;
|
|
57
|
+
activeOverlay = null;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// `/reload` and session replacements (`/new`, `/resume`, `/fork`) hide
|
|
61
|
+
// overlays without resolving `ctx.ui.custom()`, so the host never calls
|
|
62
|
+
// `dispose()`: the spinner timer would keep running and a late answer would
|
|
63
|
+
// write to an already invalidated extension runtime. pi emits
|
|
64
|
+
// `session_shutdown` before that teardown, which is the only place to stop
|
|
65
|
+
// the overlay and abort its request from the extension side.
|
|
66
|
+
pi.on("session_shutdown", () => {
|
|
67
|
+
activeOverlay?.dispose();
|
|
68
|
+
activeOverlay = null;
|
|
69
|
+
overlayOpen = false;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// `/tree` navigation swaps the active branch without restarting the session
|
|
73
|
+
// (no session_start fires), so the thread must be rebuilt from the branch
|
|
74
|
+
// that is active after the switch.
|
|
75
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
76
|
+
thread = findThread(ctx.sessionManager.getBranch());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Renders exchanges promoted with `f` inside the main transcript.
|
|
80
|
+
pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, options, theme) => {
|
|
81
|
+
const container = new Container();
|
|
82
|
+
container.addChild(
|
|
83
|
+
new Text(theme.fg("accent", "❯ btw") + theme.fg("dim", " · promoted into the main conversation"), options.outputPad, 0),
|
|
84
|
+
);
|
|
85
|
+
container.addChild(new Markdown(contentToText(message.content), options.outputPad, 0, getMarkdownTheme()));
|
|
86
|
+
return container;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
pi.registerCommand("btw", {
|
|
90
|
+
// pi 0.85.1 maps only name/description/getArgumentCompletions for extension
|
|
91
|
+
// commands, so the `[question]` placeholder for the autocomplete list has to
|
|
92
|
+
// live in the description; built-ins and prompt templates use their own
|
|
93
|
+
// `argumentHint` slot instead.
|
|
94
|
+
description: "[question] Ask a side question without adding it to the main conversation",
|
|
95
|
+
handler: async (rawArgs, ctx) => {
|
|
96
|
+
const question = rawArgs.trim();
|
|
97
|
+
|
|
98
|
+
if (ctx.mode !== "tui") {
|
|
99
|
+
ctx.ui.notify("/btw requires interactive mode", "error");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (overlayOpen) {
|
|
103
|
+
ctx.ui.notify("A btw overlay is already open", "warning");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (question === "" && thread.length === 0) {
|
|
107
|
+
ctx.ui.notify("Usage: /btw <question>", "warning");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const model = ctx.model;
|
|
111
|
+
if (question !== "" && model === undefined) {
|
|
112
|
+
ctx.ui.notify("No model selected. Pick one with /model and retry.", "error");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Snapshot the replay window now: the in-flight request must not see
|
|
116
|
+
// exchanges that finish after this command started.
|
|
117
|
+
const replayed = replayWindow(thread);
|
|
118
|
+
const sink: BtwThreadSink = {
|
|
119
|
+
add: (exchange) => {
|
|
120
|
+
thread = appendExchange(thread, exchange);
|
|
121
|
+
pi.appendEntry(BTW_ENTRY_TYPE, { kind: "exchange", exchange });
|
|
122
|
+
},
|
|
123
|
+
remove: (id) => {
|
|
124
|
+
thread = removeExchange(thread, id);
|
|
125
|
+
pi.appendEntry(BTW_ENTRY_TYPE, { kind: "delete", id });
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
overlayOpen = true;
|
|
130
|
+
let result: BtwOverlayResult;
|
|
131
|
+
try {
|
|
132
|
+
result = await ctx.ui.custom<BtwOverlayResult>(
|
|
133
|
+
(tui, theme, _keybindings, done) => {
|
|
134
|
+
const overlay = new BtwOverlay({
|
|
135
|
+
tui,
|
|
136
|
+
theme,
|
|
137
|
+
thread,
|
|
138
|
+
question: question === "" ? null : question,
|
|
139
|
+
// The model label and replay count describe the request that is
|
|
140
|
+
// about to run; the overlay stores them on the exchange it creates.
|
|
141
|
+
modelLabel: model === undefined ? "no model" : `${model.provider}/${model.id}`,
|
|
142
|
+
replayedCount: replayed.length,
|
|
143
|
+
ask: question !== "" && model !== undefined ? (signal) => askSideQuestion(ctx, model, question, replayed, signal) : undefined,
|
|
144
|
+
sink,
|
|
145
|
+
copy: copyToClipboard,
|
|
146
|
+
done,
|
|
147
|
+
});
|
|
148
|
+
activeOverlay = overlay;
|
|
149
|
+
return overlay;
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
overlay: true,
|
|
153
|
+
overlayOptions: {
|
|
154
|
+
anchor: "center",
|
|
155
|
+
width: `${OVERLAY_WIDTH_PERCENT}%`,
|
|
156
|
+
minWidth: OVERLAY_MIN_WIDTH,
|
|
157
|
+
maxHeight: `${OVERLAY_MAX_HEIGHT_PERCENT}%`,
|
|
158
|
+
margin: OVERLAY_MARGIN,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
} finally {
|
|
163
|
+
overlayOpen = false;
|
|
164
|
+
activeOverlay = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (result.action === "fork") {
|
|
168
|
+
promoteExchange(ctx, result.exchange);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Promote one side exchange into the main conversation.
|
|
175
|
+
*
|
|
176
|
+
* The exchange is appended at the session leaf as a custom message: it joins
|
|
177
|
+
* the main agent's context on the next request, and while the main agent is
|
|
178
|
+
* still streaming pi defers it to the end of the current turn instead of
|
|
179
|
+
* interrupting it. Appending never rewrites earlier history, so every cached
|
|
180
|
+
* prefix of the main conversation stays valid.
|
|
181
|
+
*/
|
|
182
|
+
function promoteExchange(ctx: ExtensionCommandContext, exchange: BtwExchange): void {
|
|
183
|
+
pi.appendEntry(BTW_ENTRY_TYPE, { kind: "promoted", id: exchange.id });
|
|
184
|
+
thread = markPromoted(thread, exchange.id);
|
|
185
|
+
pi.sendMessage({
|
|
186
|
+
customType: BTW_MESSAGE_TYPE,
|
|
187
|
+
content: formatPromotedExchange(exchange),
|
|
188
|
+
display: true,
|
|
189
|
+
details: { id: exchange.id, model: exchange.model },
|
|
190
|
+
});
|
|
191
|
+
ctx.ui.notify("btw exchange added to the main conversation", "info");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run one no-tools side request against the main session context.
|
|
197
|
+
*
|
|
198
|
+
* All provider failures are converted into a UI outcome; nothing is retried or
|
|
199
|
+
* silently swallowed, so the overlay always shows exactly what happened.
|
|
200
|
+
*/
|
|
201
|
+
async function askSideQuestion(
|
|
202
|
+
ctx: ExtensionCommandContext,
|
|
203
|
+
model: Model<any>,
|
|
204
|
+
question: string,
|
|
205
|
+
replayed: readonly BtwExchange[],
|
|
206
|
+
signal: AbortSignal,
|
|
207
|
+
): Promise<BtwQueryOutcome> {
|
|
208
|
+
// Mirror pi's own context pipeline: compaction-aware entries projected to
|
|
209
|
+
// agent messages, then transformed to provider messages.
|
|
210
|
+
try {
|
|
211
|
+
const context = convertToLlm(
|
|
212
|
+
ctx.sessionManager.buildContextEntries().flatMap((entry) => sessionEntryToContextMessages(entry)),
|
|
213
|
+
);
|
|
214
|
+
const messages = buildSideRequest({
|
|
215
|
+
context,
|
|
216
|
+
replayed,
|
|
217
|
+
question,
|
|
218
|
+
model: { api: model.api, provider: model.provider, id: model.id },
|
|
219
|
+
now: Date.now(),
|
|
220
|
+
});
|
|
221
|
+
const response = await ctx.modelRegistry.complete(
|
|
222
|
+
model,
|
|
223
|
+
{ systemPrompt: BTW_SYSTEM_PROMPT, messages },
|
|
224
|
+
{ signal, headers: sideRequestHeaders(model, ctx.sessionManager.getSessionId()) },
|
|
225
|
+
);
|
|
226
|
+
if (response.stopReason === "aborted") return { kind: "cancelled" };
|
|
227
|
+
if (response.stopReason === "error") {
|
|
228
|
+
return { kind: "error", message: response.errorMessage?.trim() || `${model.provider}/${model.id} returned an error` };
|
|
229
|
+
}
|
|
230
|
+
const answer = response.content
|
|
231
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
232
|
+
.map((block) => block.text)
|
|
233
|
+
.join("\n")
|
|
234
|
+
.trim();
|
|
235
|
+
if (answer === "") {
|
|
236
|
+
return { kind: "error", message: `${model.provider}/${model.id} returned no text (stop reason: ${response.stopReason})` };
|
|
237
|
+
}
|
|
238
|
+
return { kind: "answer", answer };
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (signal.aborted) return { kind: "cancelled" };
|
|
241
|
+
return { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** OpenCode's API host, used to detect models that need session headers. */
|
|
246
|
+
const OPENCODE_HOST = "opencode.ai";
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Extra headers a side request must carry.
|
|
250
|
+
*
|
|
251
|
+
* Pi adds provider-attribution headers inside the agent's stream wrapper
|
|
252
|
+
* (`mergeProviderAttributionHeaders` in pi's `core/provider-attribution`),
|
|
253
|
+
* which a direct `ModelRegistry.complete()` call bypasses. OpenCode rejects
|
|
254
|
+
* such requests with "MissingSessionID", so replicate the session headers
|
|
255
|
+
* that matter for routing there. Attribution headers for other providers
|
|
256
|
+
* (OpenRouter et al.) are optional and stay out of side requests.
|
|
257
|
+
*
|
|
258
|
+
* Only those session headers are replicated: the rest of pi's stream pipeline
|
|
259
|
+
* (the `sessionId` option that drives provider cache keys, request timeouts and
|
|
260
|
+
* retries, transport selection, thinking budgets, payload/response hooks and
|
|
261
|
+
* the context-window clamp on maxTokens) does not apply to a side request.
|
|
262
|
+
*/
|
|
263
|
+
function sideRequestHeaders(model: Model<any>, sessionId: string): Record<string, string> | undefined {
|
|
264
|
+
if (sessionId === "" || !isOpencodeModel(model)) return undefined;
|
|
265
|
+
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Pi's own session-header detection: opencode provider ids or an opencode.ai
|
|
270
|
+
* base URL.
|
|
271
|
+
*
|
|
272
|
+
* A base URL that does not parse means "not opencode" rather than a failed
|
|
273
|
+
* request: providers such as `azure-openai-responses` ship an empty baseUrl
|
|
274
|
+
* and resolve their endpoint from environment variables, so their side
|
|
275
|
+
* requests must still be sent without session headers.
|
|
276
|
+
*/
|
|
277
|
+
function isOpencodeModel(model: Model<any>): boolean {
|
|
278
|
+
if (model.provider === "opencode" || model.provider === "opencode-go") return true;
|
|
279
|
+
try {
|
|
280
|
+
return new URL(model.baseUrl).hostname === OPENCODE_HOST;
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Text sent to the main agent when an exchange is promoted.
|
|
288
|
+
*
|
|
289
|
+
* It doubles as the transcript rendering, so the provenance stays visible: the
|
|
290
|
+
* answer came from conversation context only and the main agent should verify
|
|
291
|
+
* it before acting on it.
|
|
292
|
+
*/
|
|
293
|
+
/**
|
|
294
|
+
* Text of the custom message that carries a promoted exchange.
|
|
295
|
+
*
|
|
296
|
+
* Exported because tools/make-doc-images.mjs renders the README screenshot
|
|
297
|
+
* through this exact formatter, so the documented output cannot drift from the
|
|
298
|
+
* text the main agent receives.
|
|
299
|
+
*/
|
|
300
|
+
export function formatPromotedExchange(exchange: BtwExchange): string {
|
|
301
|
+
const quotedQuestion = exchange.question
|
|
302
|
+
.split("\n")
|
|
303
|
+
.map((line) => `> ${line}`)
|
|
304
|
+
.join("\n");
|
|
305
|
+
return [
|
|
306
|
+
"_[btw] The user promoted this side exchange into the main conversation. The answer was produced without tools from conversation context only; verify it before relying on it._",
|
|
307
|
+
"",
|
|
308
|
+
quotedQuestion,
|
|
309
|
+
"",
|
|
310
|
+
exchange.answer,
|
|
311
|
+
].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function contentToText(content: string | readonly (TextContent | ImageContent)[]): string {
|
|
315
|
+
if (typeof content === "string") return content;
|
|
316
|
+
return content
|
|
317
|
+
.map((block) => (block.type === "text" ? block.text : "[image]"))
|
|
318
|
+
.join("\n");
|
|
319
|
+
}
|