@narumitw/pi-btw 0.18.0 → 0.25.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/README.md +18 -5
- package/package.json +5 -5
- package/src/btw.ts +172 -383
- package/src/side-thread.ts +247 -0
- package/src/transcript-pager.ts +378 -0
package/README.md
CHANGED
|
@@ -8,8 +8,9 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
|
|
|
8
8
|
|
|
9
9
|
## ✨ Features
|
|
10
10
|
|
|
11
|
-
- Adds a `/btw
|
|
11
|
+
- Adds a `/btw` side-thread command to Pi, with an optional initial question.
|
|
12
12
|
- Answers side questions in a temporary, scrollable UI.
|
|
13
|
+
- Supports follow-up questions in the same ephemeral side thread.
|
|
13
14
|
- Uses the current session branch as context.
|
|
14
15
|
- Uses Pi's current model or an independent model selected in `pi-btw.json`.
|
|
15
16
|
- Inherits Pi's current thinking level or uses a fixed level from `pi-btw.json`.
|
|
@@ -36,22 +37,34 @@ pi -e ./extensions/pi-btw
|
|
|
36
37
|
|
|
37
38
|
## 🚀 Usage
|
|
38
39
|
|
|
40
|
+
Start an empty side thread or provide its first question immediately:
|
|
41
|
+
|
|
39
42
|
```text
|
|
43
|
+
/btw
|
|
40
44
|
/btw <your side question>
|
|
41
45
|
```
|
|
42
46
|
|
|
43
47
|
Examples:
|
|
44
48
|
|
|
45
49
|
```text
|
|
50
|
+
/btw
|
|
46
51
|
/btw what does this TypeScript error mean?
|
|
47
52
|
/btw summarize the current implementation before we continue
|
|
48
53
|
/btw is this API name idiomatic?
|
|
49
54
|
```
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
`
|
|
54
|
-
|
|
56
|
+
Running `/btw` alone opens an empty ephemeral side thread with its editor ready. When an
|
|
57
|
+
initial question is provided, its answer opens above the same editor. A compact
|
|
58
|
+
`btw · side thread` header stays fixed above the content so the ephemeral workspace remains
|
|
59
|
+
recognizable while scrolling. Messages use Pi's normal user and assistant presentation without
|
|
60
|
+
numbered turns or role labels. Type each question and press `Enter`; no follow-up shortcut is
|
|
61
|
+
required.
|
|
62
|
+
Previous side questions and answers remain available to the model and visible for that
|
|
63
|
+
invocation. While a response is running, the transcript stays visible above a compact
|
|
64
|
+
`Answering…` status. The footer shows `PgUp`/`PgDn` only when history can scroll; press
|
|
65
|
+
`Ctrl+C` to cancel an in-progress answer or leave the side thread. Closing it, reloading Pi,
|
|
66
|
+
or switching sessions discards it without adding any of its questions or answers to the main
|
|
67
|
+
conversation.
|
|
55
68
|
|
|
56
69
|
## ⚙️ Model and thinking level
|
|
57
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-btw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Pi extension that adds a /btw side-question command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@biomejs/biome": "2.5.3",
|
|
32
|
-
"@earendil-works/pi-ai": "0.80.
|
|
33
|
-
"@earendil-works/pi-coding-agent": "0.80.
|
|
34
|
-
"@earendil-works/pi-tui": "0.80.
|
|
35
|
-
"typescript": "
|
|
32
|
+
"@earendil-works/pi-ai": "0.80.10",
|
|
33
|
+
"@earendil-works/pi-coding-agent": "0.80.10",
|
|
34
|
+
"@earendil-works/pi-tui": "0.80.10",
|
|
35
|
+
"typescript": "7.0.2"
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
package/src/btw.ts
CHANGED
|
@@ -1,83 +1,36 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type {
|
|
4
|
-
Api,
|
|
5
|
-
AssistantMessage,
|
|
6
|
-
Context,
|
|
7
|
-
Model,
|
|
8
|
-
SimpleStreamOptions,
|
|
9
|
-
UserMessage,
|
|
10
|
-
} from "@earendil-works/pi-ai";
|
|
11
|
-
// pi-ai 0.79 exports completeSimple from the root; 0.80 moved it to the compat subpath.
|
|
12
|
-
type CompleteSimpleFunction = <TApi extends Api>(
|
|
13
|
-
model: Model<TApi>,
|
|
14
|
-
context: Context,
|
|
15
|
-
options?: SimpleStreamOptions,
|
|
16
|
-
) => Promise<AssistantMessage>;
|
|
17
|
-
|
|
18
|
-
function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
|
|
19
|
-
return (
|
|
20
|
-
typeof value === "object" &&
|
|
21
|
-
value !== null &&
|
|
22
|
-
typeof Reflect.get(value, "completeSimple") === "function"
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
type ModuleImporter = (moduleId: string) => Promise<unknown>;
|
|
27
|
-
|
|
28
|
-
export async function loadCompleteSimple(
|
|
29
|
-
importModule: ModuleImporter = (moduleId) => import(moduleId),
|
|
30
|
-
): Promise<CompleteSimpleFunction> {
|
|
31
|
-
let importError: unknown;
|
|
32
|
-
for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
|
|
33
|
-
try {
|
|
34
|
-
const module = await importModule(moduleId);
|
|
35
|
-
if (hasCompleteSimple(module)) return module.completeSimple;
|
|
36
|
-
} catch (error: unknown) {
|
|
37
|
-
importError = error;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
throw new Error("@earendil-works/pi-ai does not export completeSimple", {
|
|
42
|
-
cause: importError,
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const completeSimple = await loadCompleteSimple();
|
|
3
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
47
4
|
import {
|
|
48
5
|
BorderedLoader,
|
|
49
|
-
DynamicBorder,
|
|
50
|
-
getAgentDir,
|
|
51
|
-
getMarkdownTheme,
|
|
52
6
|
type ExtensionAPI,
|
|
53
7
|
type ExtensionCommandContext,
|
|
54
|
-
|
|
8
|
+
getAgentDir,
|
|
55
9
|
} from "@earendil-works/pi-coding-agent";
|
|
56
10
|
import {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
type
|
|
63
|
-
|
|
64
|
-
|
|
11
|
+
BTW_THINKING_LEVELS,
|
|
12
|
+
type BtwThinkingLevel,
|
|
13
|
+
completeSideThreadTurn,
|
|
14
|
+
createSideThread,
|
|
15
|
+
type SideQuestionAuth,
|
|
16
|
+
type SideThread,
|
|
17
|
+
} from "./side-thread.js";
|
|
18
|
+
import {
|
|
19
|
+
BtwAnsweringView,
|
|
20
|
+
BtwTranscriptPager,
|
|
21
|
+
type TranscriptPagerAction,
|
|
22
|
+
} from "./transcript-pager.js";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
BTW_THINKING_LEVELS,
|
|
26
|
+
type BtwThinkingLevel,
|
|
27
|
+
buildUserPrompt,
|
|
28
|
+
completeSideQuestion,
|
|
29
|
+
loadCompleteSimple,
|
|
30
|
+
} from "./side-thread.js";
|
|
65
31
|
|
|
66
32
|
const MAX_CONTEXT_CHARS = 40_000;
|
|
67
|
-
const ANSWER_CHROME_LINES = 4;
|
|
68
|
-
// Pi renders a spacer above the custom editor and a two-line built-in footer below it.
|
|
69
|
-
const ANSWER_RESERVED_APP_LINES = 3;
|
|
70
33
|
export const BTW_SETTINGS_FILE = "pi-btw.json";
|
|
71
|
-
export const BTW_THINKING_LEVELS = [
|
|
72
|
-
"off",
|
|
73
|
-
"minimal",
|
|
74
|
-
"low",
|
|
75
|
-
"medium",
|
|
76
|
-
"high",
|
|
77
|
-
"xhigh",
|
|
78
|
-
] as const;
|
|
79
|
-
|
|
80
|
-
export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
|
|
81
34
|
|
|
82
35
|
export interface BtwSettings {
|
|
83
36
|
model?: string;
|
|
@@ -94,15 +47,11 @@ interface LoadBtwThinkingLevelOptions {
|
|
|
94
47
|
warn?: (message: string) => void;
|
|
95
48
|
}
|
|
96
49
|
|
|
97
|
-
interface SideQuestionAuth {
|
|
98
|
-
apiKey?: string;
|
|
99
|
-
headers?: Record<string, string>;
|
|
100
|
-
env?: Record<string, string>;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
50
|
interface BtwModelRegistry {
|
|
104
51
|
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
105
|
-
getApiKeyAndHeaders(
|
|
52
|
+
getApiKeyAndHeaders(
|
|
53
|
+
model: Model<Api>,
|
|
54
|
+
): Promise<
|
|
106
55
|
| { ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
|
|
107
56
|
| { ok: false; error: string }
|
|
108
57
|
>;
|
|
@@ -115,19 +64,9 @@ interface ResolveBtwModelOptions {
|
|
|
115
64
|
warn?: (message: string) => void;
|
|
116
65
|
}
|
|
117
66
|
|
|
118
|
-
interface ResolvedBtwModel {
|
|
119
|
-
model: Model<Api>;
|
|
120
|
-
auth: SideQuestionAuth;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface CompleteSideQuestionOptions {
|
|
67
|
+
export interface ResolvedBtwModel {
|
|
124
68
|
model: Model<Api>;
|
|
125
|
-
question: string;
|
|
126
|
-
conversationContext: string;
|
|
127
|
-
thinkingLevel: BtwThinkingLevel;
|
|
128
69
|
auth: SideQuestionAuth;
|
|
129
|
-
signal?: AbortSignal;
|
|
130
|
-
completeSimple?: CompleteSimpleFunction;
|
|
131
70
|
}
|
|
132
71
|
|
|
133
72
|
export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
|
|
@@ -166,14 +105,19 @@ export async function resolveBtwModel({
|
|
|
166
105
|
const fallback = currentModel
|
|
167
106
|
? `${currentModel.provider}/${currentModel.id}`
|
|
168
107
|
: "the current model";
|
|
169
|
-
const reference = parseBtwModelReference(settings.model)
|
|
108
|
+
const reference = parseBtwModelReference(settings.model);
|
|
109
|
+
if (!reference) {
|
|
110
|
+
warn?.(`pi-btw model ${settings.model} is invalid; falling back to ${fallback}.`);
|
|
111
|
+
return resolveBtwModel({ settings: {}, currentModel, modelRegistry, warn });
|
|
112
|
+
}
|
|
170
113
|
const configuredModel = modelRegistry.find(reference.provider, reference.modelId);
|
|
171
114
|
if (!configuredModel) {
|
|
172
115
|
warn?.(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
|
|
173
116
|
} else {
|
|
174
117
|
const sameAsCurrent =
|
|
175
118
|
configuredModel === currentModel ||
|
|
176
|
-
(configuredModel.provider === currentModel?.provider &&
|
|
119
|
+
(configuredModel.provider === currentModel?.provider &&
|
|
120
|
+
configuredModel.id === currentModel.id);
|
|
177
121
|
const fallbackAction = sameAsCurrent
|
|
178
122
|
? "no distinct current model is available"
|
|
179
123
|
: `falling back to ${fallback}`;
|
|
@@ -181,9 +125,7 @@ export async function resolveBtwModel({
|
|
|
181
125
|
const auth = await modelRegistry.getApiKeyAndHeaders(configuredModel);
|
|
182
126
|
if (auth.ok && hasRequestAuth(auth)) return { model: configuredModel, auth };
|
|
183
127
|
const reason = auth.ok ? "has no request credentials" : auth.error;
|
|
184
|
-
warn?.(
|
|
185
|
-
`pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`,
|
|
186
|
-
);
|
|
128
|
+
warn?.(`pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`);
|
|
187
129
|
} catch (error: unknown) {
|
|
188
130
|
warn?.(
|
|
189
131
|
`pi-btw model ${settings.model} credentials failed (${formatError(error)}); ${fallbackAction}.`,
|
|
@@ -247,36 +189,6 @@ export async function loadBtwThinkingLevel(
|
|
|
247
189
|
return currentThinkingLevel;
|
|
248
190
|
}
|
|
249
191
|
|
|
250
|
-
export async function completeSideQuestion({
|
|
251
|
-
model,
|
|
252
|
-
question,
|
|
253
|
-
conversationContext,
|
|
254
|
-
thinkingLevel,
|
|
255
|
-
auth,
|
|
256
|
-
signal,
|
|
257
|
-
completeSimple: runCompleteSimple = completeSimple,
|
|
258
|
-
}: CompleteSideQuestionOptions): Promise<AssistantMessage> {
|
|
259
|
-
const userMessage: UserMessage = {
|
|
260
|
-
role: "user",
|
|
261
|
-
content: [
|
|
262
|
-
{
|
|
263
|
-
type: "text",
|
|
264
|
-
text: buildUserPrompt(question, conversationContext),
|
|
265
|
-
},
|
|
266
|
-
],
|
|
267
|
-
timestamp: Date.now(),
|
|
268
|
-
};
|
|
269
|
-
const streamOptions: SimpleStreamOptions = {
|
|
270
|
-
apiKey: auth.apiKey,
|
|
271
|
-
headers: auth.headers,
|
|
272
|
-
env: auth.env,
|
|
273
|
-
signal,
|
|
274
|
-
};
|
|
275
|
-
if (thinkingLevel !== "off") streamOptions.reasoning = thinkingLevel;
|
|
276
|
-
|
|
277
|
-
return runCompleteSimple(model, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, streamOptions);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
192
|
function isBtwThinkingLevel(value: unknown): value is BtwThinkingLevel {
|
|
281
193
|
return BTW_THINKING_LEVELS.includes(value as BtwThinkingLevel);
|
|
282
194
|
}
|
|
@@ -289,52 +201,17 @@ function formatError(error: unknown): string {
|
|
|
289
201
|
return error instanceof Error ? error.message : String(error);
|
|
290
202
|
}
|
|
291
203
|
|
|
292
|
-
const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
|
|
293
|
-
|
|
294
|
-
Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
|
|
295
|
-
|
|
296
|
-
type MessageContentBlock = {
|
|
297
|
-
type?: string;
|
|
298
|
-
text?: string;
|
|
299
|
-
name?: string;
|
|
300
|
-
arguments?: unknown;
|
|
301
|
-
result?: unknown;
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
type SessionMessage = {
|
|
305
|
-
role?: string;
|
|
306
|
-
content?: unknown;
|
|
307
|
-
stopReason?: string;
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
type SessionEntry = {
|
|
311
|
-
type: string;
|
|
312
|
-
message?: SessionMessage;
|
|
313
|
-
};
|
|
314
|
-
|
|
315
204
|
export default function btw(pi: ExtensionAPI) {
|
|
316
205
|
pi.registerCommand("btw", {
|
|
317
206
|
description: "Ask a quick side question without adding it to the main conversation",
|
|
318
207
|
handler: async (args, ctx) => {
|
|
319
208
|
const question = args.trim();
|
|
320
|
-
if (
|
|
321
|
-
ctx.ui.notify("
|
|
209
|
+
if (ctx.mode !== "tui") {
|
|
210
|
+
ctx.ui.notify("/btw requires interactive TUI mode", "error");
|
|
322
211
|
return;
|
|
323
212
|
}
|
|
324
213
|
|
|
325
|
-
|
|
326
|
-
ctx.ui.notify("/btw requires interactive mode", "error");
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const settingsResult = await readBtwSettings();
|
|
331
|
-
let settings: BtwSettings = {};
|
|
332
|
-
if (settingsResult.kind === "loaded") {
|
|
333
|
-
settings = settingsResult.settings;
|
|
334
|
-
} else if (settingsResult.kind === "invalid") {
|
|
335
|
-
ctx.ui.notify(`pi-btw settings ignored: ${settingsResult.reason}`, "warning");
|
|
336
|
-
}
|
|
337
|
-
|
|
214
|
+
const settings = await loadSettingsForCommand(ctx);
|
|
338
215
|
const resolution = await resolveBtwModelWithLoader(settings, ctx);
|
|
339
216
|
if (resolution.kind === "cancelled") {
|
|
340
217
|
ctx.ui.notify("Cancelled", "info");
|
|
@@ -345,18 +222,25 @@ export default function btw(pi: ExtensionAPI) {
|
|
|
345
222
|
return;
|
|
346
223
|
}
|
|
347
224
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
await showAnswer(question, answer, ctx);
|
|
225
|
+
await runBtwThread({
|
|
226
|
+
initialQuestion: question || undefined,
|
|
227
|
+
selected: resolution.selected,
|
|
228
|
+
thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
|
|
229
|
+
ctx,
|
|
230
|
+
});
|
|
356
231
|
},
|
|
357
232
|
});
|
|
358
233
|
}
|
|
359
234
|
|
|
235
|
+
async function loadSettingsForCommand(ctx: ExtensionCommandContext): Promise<BtwSettings> {
|
|
236
|
+
const settingsResult = await readBtwSettings();
|
|
237
|
+
if (settingsResult.kind === "loaded") return settingsResult.settings;
|
|
238
|
+
if (settingsResult.kind === "invalid") {
|
|
239
|
+
ctx.ui.notify(`pi-btw settings ignored: ${settingsResult.reason}`, "warning");
|
|
240
|
+
}
|
|
241
|
+
return {};
|
|
242
|
+
}
|
|
243
|
+
|
|
360
244
|
type ModelResolutionOutcome =
|
|
361
245
|
| { kind: "cancelled" }
|
|
362
246
|
| { kind: "unavailable" }
|
|
@@ -368,9 +252,10 @@ async function resolveBtwModelWithLoader(
|
|
|
368
252
|
): Promise<ModelResolutionOutcome> {
|
|
369
253
|
return ctx.ui.custom<ModelResolutionOutcome>((tui, theme, _keybindings, done) => {
|
|
370
254
|
const loader = new BorderedLoader(tui, theme, "Resolving /btw model credentials...");
|
|
371
|
-
let
|
|
255
|
+
let settled = false;
|
|
372
256
|
loader.onAbort = () => {
|
|
373
|
-
|
|
257
|
+
if (settled) return;
|
|
258
|
+
settled = true;
|
|
374
259
|
done({ kind: "cancelled" });
|
|
375
260
|
};
|
|
376
261
|
|
|
@@ -379,235 +264,146 @@ async function resolveBtwModelWithLoader(
|
|
|
379
264
|
currentModel: ctx.model,
|
|
380
265
|
modelRegistry: ctx.modelRegistry,
|
|
381
266
|
warn: (message) => {
|
|
382
|
-
if (!
|
|
267
|
+
if (!settled) ctx.ui.notify(message, "warning");
|
|
383
268
|
},
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
question: string,
|
|
395
|
-
selected: ResolvedBtwModel,
|
|
396
|
-
thinkingLevel: BtwThinkingLevel,
|
|
397
|
-
ctx: ExtensionCommandContext,
|
|
398
|
-
): Promise<string | undefined> {
|
|
399
|
-
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
400
|
-
const loader = new BorderedLoader(
|
|
401
|
-
tui,
|
|
402
|
-
theme,
|
|
403
|
-
`Answering /btw with ${selected.model.provider}/${selected.model.id}...`,
|
|
404
|
-
);
|
|
405
|
-
loader.onAbort = () => done(undefined);
|
|
406
|
-
|
|
407
|
-
const ask = async () => {
|
|
408
|
-
const conversationContext = buildConversationContext(ctx.sessionManager.getBranch());
|
|
409
|
-
const response = await completeSideQuestion({
|
|
410
|
-
model: selected.model,
|
|
411
|
-
question,
|
|
412
|
-
conversationContext,
|
|
413
|
-
thinkingLevel,
|
|
414
|
-
auth: selected.auth,
|
|
415
|
-
signal: loader.signal,
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
if (response.stopReason === "aborted") {
|
|
419
|
-
return undefined;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const text = response.content
|
|
423
|
-
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
424
|
-
.map((content) => content.text)
|
|
425
|
-
.join("\n")
|
|
426
|
-
.trim();
|
|
427
|
-
|
|
428
|
-
return text || "No response received.";
|
|
429
|
-
};
|
|
430
|
-
|
|
431
|
-
ask()
|
|
432
|
-
.then(done)
|
|
433
|
-
.catch((error: unknown) => {
|
|
434
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
435
|
-
done(`Error: ${message}`);
|
|
269
|
+
})
|
|
270
|
+
.then((selected) => {
|
|
271
|
+
if (settled) return;
|
|
272
|
+
settled = true;
|
|
273
|
+
done(selected ? { kind: "selected", selected } : { kind: "unavailable" });
|
|
274
|
+
})
|
|
275
|
+
.catch(() => {
|
|
276
|
+
if (settled) return;
|
|
277
|
+
settled = true;
|
|
278
|
+
done({ kind: "unavailable" });
|
|
436
279
|
});
|
|
437
280
|
|
|
438
281
|
return loader;
|
|
439
282
|
});
|
|
440
283
|
}
|
|
441
284
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
});
|
|
285
|
+
interface RunBtwThreadDependencies {
|
|
286
|
+
ask?: typeof askThreadQuestion;
|
|
287
|
+
interact?: typeof showThreadComposer;
|
|
446
288
|
}
|
|
447
289
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
private readonly markdown: Markdown;
|
|
456
|
-
private scrollOffset = 0;
|
|
457
|
-
private lastContentLineCount = 0;
|
|
458
|
-
private lastViewportHeight = 1;
|
|
459
|
-
|
|
460
|
-
constructor(tui: TUI, theme: Theme, question: string, answer: string, onClose: () => void) {
|
|
461
|
-
this.tui = tui;
|
|
462
|
-
this.theme = theme;
|
|
463
|
-
this.title = sanitizeSingleLine(`/btw ${question}`);
|
|
464
|
-
this.onClose = onClose;
|
|
465
|
-
const borderColor = (text: string) => this.theme.fg("warning", text);
|
|
466
|
-
this.topBorder = new DynamicBorder(borderColor);
|
|
467
|
-
this.bottomBorder = new DynamicBorder(borderColor);
|
|
468
|
-
this.markdown = new Markdown(answer, 1, 1, getMarkdownTheme());
|
|
469
|
-
}
|
|
290
|
+
interface RunBtwThreadOptions {
|
|
291
|
+
initialQuestion?: string;
|
|
292
|
+
selected: ResolvedBtwModel;
|
|
293
|
+
thinkingLevel: BtwThinkingLevel;
|
|
294
|
+
ctx: ExtensionCommandContext;
|
|
295
|
+
dependencies?: RunBtwThreadDependencies;
|
|
296
|
+
}
|
|
470
297
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
];
|
|
490
|
-
}
|
|
298
|
+
export async function runBtwThread({
|
|
299
|
+
initialQuestion,
|
|
300
|
+
selected,
|
|
301
|
+
thinkingLevel,
|
|
302
|
+
ctx,
|
|
303
|
+
dependencies = {},
|
|
304
|
+
}: RunBtwThreadOptions): Promise<void> {
|
|
305
|
+
const ask = dependencies.ask ?? askThreadQuestion;
|
|
306
|
+
const interact = dependencies.interact ?? showThreadComposer;
|
|
307
|
+
const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
|
|
308
|
+
let pendingQuestion = initialQuestion;
|
|
309
|
+
|
|
310
|
+
while (true) {
|
|
311
|
+
if (!pendingQuestion) {
|
|
312
|
+
const action = await interact(thread, thread.turns.length > 0, ctx);
|
|
313
|
+
if (action.kind === "close") return;
|
|
314
|
+
pendingQuestion = action.question;
|
|
315
|
+
}
|
|
491
316
|
|
|
492
|
-
|
|
493
|
-
if (
|
|
494
|
-
|
|
317
|
+
const result = await ask(thread, pendingQuestion, selected, thinkingLevel, ctx);
|
|
318
|
+
if (result.kind === "aborted") {
|
|
319
|
+
ctx.ui.notify("Cancelled", "info");
|
|
495
320
|
return;
|
|
496
321
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
matchesKey(data, Key.pageUp) ||
|
|
504
|
-
matchesKey(data, Key.shift(Key.space)) ||
|
|
505
|
-
matchesKey(data, Key.ctrl("b"))
|
|
506
|
-
) {
|
|
507
|
-
this.scrollBy(-this.lastViewportHeight);
|
|
508
|
-
} else if (
|
|
509
|
-
matchesKey(data, Key.pageDown) ||
|
|
510
|
-
matchesKey(data, Key.space) ||
|
|
511
|
-
matchesKey(data, Key.ctrl("f"))
|
|
512
|
-
) {
|
|
513
|
-
this.scrollBy(this.lastViewportHeight);
|
|
514
|
-
} else if (matchesKey(data, Key.ctrl("u"))) {
|
|
515
|
-
this.scrollBy(-this.getHalfPageHeight());
|
|
516
|
-
} else if (matchesKey(data, Key.ctrl("d"))) {
|
|
517
|
-
this.scrollBy(this.getHalfPageHeight());
|
|
518
|
-
} else if (matchesKey(data, Key.home)) {
|
|
519
|
-
this.scrollOffset = 0;
|
|
520
|
-
} else if (matchesKey(data, Key.end)) {
|
|
521
|
-
this.scrollOffset = this.getMaxScrollOffset();
|
|
322
|
+
if (result.kind === "error") {
|
|
323
|
+
thread.turns.push({
|
|
324
|
+
kind: "error",
|
|
325
|
+
question: pendingQuestion,
|
|
326
|
+
answer: result.message,
|
|
327
|
+
});
|
|
522
328
|
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
invalidate(): void {
|
|
526
|
-
this.topBorder.invalidate();
|
|
527
|
-
this.bottomBorder.invalidate();
|
|
528
|
-
this.markdown.invalidate();
|
|
529
|
-
}
|
|
530
329
|
|
|
531
|
-
|
|
532
|
-
return (
|
|
533
|
-
matchesKey(data, "q") ||
|
|
534
|
-
matchesKey(data, Key.escape) ||
|
|
535
|
-
matchesKey(data, Key.enter) ||
|
|
536
|
-
matchesKey(data, Key.return) ||
|
|
537
|
-
matchesKey(data, Key.ctrl("c"))
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
private renderTitle(width: number): string {
|
|
542
|
-
return truncateToWidth(this.theme.fg("warning", this.theme.bold(this.title)), width);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
private renderFooter(width: number): string {
|
|
546
|
-
const progress = this.formatProgress();
|
|
547
|
-
const hints = "↑↓/j/k scroll • PgUp/PgDn page • Home/End jump • q/Esc close";
|
|
548
|
-
const progressWidth = visibleWidth(progress);
|
|
549
|
-
const footer =
|
|
550
|
-
progressWidth + 3 >= width
|
|
551
|
-
? truncateToWidth(progress, width)
|
|
552
|
-
: `${truncateToWidth(hints, width - progressWidth - 3)} • ${progress}`;
|
|
553
|
-
return this.theme.fg("dim", footer);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
private formatProgress(): string {
|
|
557
|
-
const total = this.lastContentLineCount;
|
|
558
|
-
if (total === 0) return "100% 0-0/0";
|
|
559
|
-
|
|
560
|
-
const maxScroll = this.getMaxScrollOffset();
|
|
561
|
-
const percent = maxScroll === 0 ? 100 : Math.round((this.scrollOffset / maxScroll) * 100);
|
|
562
|
-
const firstLine = this.scrollOffset + 1;
|
|
563
|
-
const lastLine = Math.min(total, this.scrollOffset + this.lastViewportHeight);
|
|
564
|
-
|
|
565
|
-
return `${percent}% ${firstLine}-${lastLine}/${total}`;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
private scrollBy(delta: number): void {
|
|
569
|
-
this.scrollOffset += delta;
|
|
570
|
-
this.clampScrollOffset();
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
private clampScrollOffset(): void {
|
|
574
|
-
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
private getMaxScrollOffset(): number {
|
|
578
|
-
return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
|
|
330
|
+
pendingQuestion = undefined;
|
|
579
331
|
}
|
|
332
|
+
}
|
|
580
333
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
334
|
+
async function askThreadQuestion(
|
|
335
|
+
thread: SideThread,
|
|
336
|
+
question: string,
|
|
337
|
+
selected: ResolvedBtwModel,
|
|
338
|
+
thinkingLevel: BtwThinkingLevel,
|
|
339
|
+
ctx: ExtensionCommandContext,
|
|
340
|
+
) {
|
|
341
|
+
return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
|
|
342
|
+
(tui, theme, _keybindings, done) => {
|
|
343
|
+
let settled = false;
|
|
344
|
+
const view = new BtwAnsweringView(tui, theme, thread.turns, question, () => {
|
|
345
|
+
if (settled) return;
|
|
346
|
+
settled = true;
|
|
347
|
+
done({ kind: "aborted" });
|
|
348
|
+
});
|
|
349
|
+
completeSideThreadTurn({
|
|
350
|
+
thread,
|
|
351
|
+
question,
|
|
352
|
+
model: selected.model,
|
|
353
|
+
thinkingLevel,
|
|
354
|
+
auth: selected.auth,
|
|
355
|
+
signal: view.signal,
|
|
356
|
+
}).then((result) => {
|
|
357
|
+
if (settled) return;
|
|
358
|
+
settled = true;
|
|
359
|
+
view.finish();
|
|
360
|
+
done(result);
|
|
361
|
+
});
|
|
362
|
+
return view;
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
}
|
|
584
366
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
367
|
+
async function showThreadComposer(
|
|
368
|
+
thread: SideThread,
|
|
369
|
+
startAtBottom: boolean,
|
|
370
|
+
ctx: ExtensionCommandContext,
|
|
371
|
+
): Promise<TranscriptPagerAction> {
|
|
372
|
+
return ctx.ui.custom<TranscriptPagerAction>(
|
|
373
|
+
(tui, theme, _keybindings, done) =>
|
|
374
|
+
new BtwTranscriptPager(tui, theme, thread.turns, done, { startAtBottom }),
|
|
375
|
+
);
|
|
588
376
|
}
|
|
589
377
|
|
|
590
378
|
export function sanitizeSingleLine(text: string) {
|
|
591
|
-
return text
|
|
592
|
-
.
|
|
593
|
-
|
|
379
|
+
return [...text.replace(/[\r\n\t]/g, " ")]
|
|
380
|
+
.filter((character) => {
|
|
381
|
+
const code = character.charCodeAt(0);
|
|
382
|
+
return code > 31 && (code < 127 || code > 159);
|
|
383
|
+
})
|
|
384
|
+
.join("")
|
|
594
385
|
.replace(/ +/g, " ")
|
|
595
386
|
.trim();
|
|
596
387
|
}
|
|
597
388
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
389
|
+
type MessageContentBlock = {
|
|
390
|
+
type?: string;
|
|
391
|
+
text?: string;
|
|
392
|
+
name?: string;
|
|
393
|
+
arguments?: unknown;
|
|
394
|
+
result?: unknown;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
type SessionMessage = {
|
|
398
|
+
role?: string;
|
|
399
|
+
content?: unknown;
|
|
400
|
+
stopReason?: string;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
type SessionEntry = {
|
|
404
|
+
type: string;
|
|
405
|
+
message?: SessionMessage;
|
|
406
|
+
};
|
|
611
407
|
|
|
612
408
|
export function buildConversationContext(entries: readonly SessionEntry[]) {
|
|
613
409
|
const sections: string[] = [];
|
|
@@ -633,18 +429,12 @@ export function buildConversationContext(entries: readonly SessionEntry[]) {
|
|
|
633
429
|
}
|
|
634
430
|
|
|
635
431
|
function extractContentLines(content: unknown): string[] {
|
|
636
|
-
if (typeof content === "string")
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (!Array.isArray(content)) {
|
|
641
|
-
return [];
|
|
642
|
-
}
|
|
432
|
+
if (typeof content === "string") return [content.trim()].filter(Boolean);
|
|
433
|
+
if (!Array.isArray(content)) return [];
|
|
643
434
|
|
|
644
435
|
const lines: string[] = [];
|
|
645
436
|
for (const part of content) {
|
|
646
437
|
if (!part || typeof part !== "object") continue;
|
|
647
|
-
|
|
648
438
|
const block = part as MessageContentBlock;
|
|
649
439
|
if (block.type === "text" && typeof block.text === "string") {
|
|
650
440
|
lines.push(block.text.trim());
|
|
@@ -654,7 +444,6 @@ function extractContentLines(content: unknown): string[] {
|
|
|
654
444
|
lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
|
|
655
445
|
}
|
|
656
446
|
}
|
|
657
|
-
|
|
658
447
|
return lines.filter(Boolean);
|
|
659
448
|
}
|
|
660
449
|
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
Context,
|
|
5
|
+
Message,
|
|
6
|
+
Model,
|
|
7
|
+
SimpleStreamOptions,
|
|
8
|
+
UserMessage,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
10
|
+
|
|
11
|
+
export const BTW_THINKING_LEVELS = [
|
|
12
|
+
"off",
|
|
13
|
+
"minimal",
|
|
14
|
+
"low",
|
|
15
|
+
"medium",
|
|
16
|
+
"high",
|
|
17
|
+
"xhigh",
|
|
18
|
+
"max",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
|
|
22
|
+
|
|
23
|
+
export interface SideQuestionAuth {
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CompleteSimpleFunction = <TApi extends Api>(
|
|
30
|
+
model: Model<TApi>,
|
|
31
|
+
context: Context,
|
|
32
|
+
options?: SimpleStreamOptions,
|
|
33
|
+
) => Promise<AssistantMessage>;
|
|
34
|
+
|
|
35
|
+
type ModuleImporter = (moduleId: string) => Promise<unknown>;
|
|
36
|
+
|
|
37
|
+
function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
|
|
38
|
+
return (
|
|
39
|
+
typeof value === "object" &&
|
|
40
|
+
value !== null &&
|
|
41
|
+
typeof Reflect.get(value, "completeSimple") === "function"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function loadCompleteSimple(
|
|
46
|
+
importModule: ModuleImporter = (moduleId) => import(moduleId),
|
|
47
|
+
): Promise<CompleteSimpleFunction> {
|
|
48
|
+
let importError: unknown;
|
|
49
|
+
for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
|
|
50
|
+
try {
|
|
51
|
+
const module = await importModule(moduleId);
|
|
52
|
+
if (hasCompleteSimple(module)) return module.completeSimple;
|
|
53
|
+
} catch (error: unknown) {
|
|
54
|
+
importError = error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error("@earendil-works/pi-ai does not export completeSimple", {
|
|
59
|
+
cause: importError,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultCompleteSimple = await loadCompleteSimple();
|
|
64
|
+
|
|
65
|
+
export type SideThreadTurn =
|
|
66
|
+
| {
|
|
67
|
+
kind: "answered";
|
|
68
|
+
question: string;
|
|
69
|
+
answer: string;
|
|
70
|
+
response: AssistantMessage;
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
kind: "error";
|
|
74
|
+
question: string;
|
|
75
|
+
answer: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export interface SideThread {
|
|
79
|
+
conversationContext: string;
|
|
80
|
+
turns: SideThreadTurn[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createSideThread(conversationContext: string): SideThread {
|
|
84
|
+
return { conversationContext, turns: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildSideThreadMessages(thread: SideThread, question: string): Message[] {
|
|
88
|
+
const answeredTurns = thread.turns.filter(
|
|
89
|
+
(turn): turn is Extract<SideThreadTurn, { kind: "answered" }> => turn.kind === "answered",
|
|
90
|
+
);
|
|
91
|
+
const messages: Message[] = [];
|
|
92
|
+
|
|
93
|
+
if (answeredTurns.length === 0) {
|
|
94
|
+
messages.push(createUserMessage(buildUserPrompt(question, thread.conversationContext)));
|
|
95
|
+
return messages;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const [first, ...rest] = answeredTurns;
|
|
99
|
+
messages.push(
|
|
100
|
+
createUserMessage(buildUserPrompt(first.question, thread.conversationContext)),
|
|
101
|
+
first.response,
|
|
102
|
+
);
|
|
103
|
+
for (const turn of rest) {
|
|
104
|
+
messages.push(createUserMessage(buildFollowUpPrompt(turn.question)), turn.response);
|
|
105
|
+
}
|
|
106
|
+
messages.push(createUserMessage(buildFollowUpPrompt(question)));
|
|
107
|
+
return messages;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface CompleteSideThreadTurnOptions {
|
|
111
|
+
thread: SideThread;
|
|
112
|
+
model: Model<Api>;
|
|
113
|
+
question: string;
|
|
114
|
+
thinkingLevel: BtwThinkingLevel;
|
|
115
|
+
auth: SideQuestionAuth;
|
|
116
|
+
signal?: AbortSignal;
|
|
117
|
+
completeSimple?: CompleteSimpleFunction;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type CompleteSideThreadTurnResult =
|
|
121
|
+
| { kind: "answered"; response: AssistantMessage; answer: string }
|
|
122
|
+
| { kind: "aborted" }
|
|
123
|
+
| { kind: "error"; message: string };
|
|
124
|
+
|
|
125
|
+
export async function completeSideThreadTurn({
|
|
126
|
+
thread,
|
|
127
|
+
model,
|
|
128
|
+
question,
|
|
129
|
+
thinkingLevel,
|
|
130
|
+
auth,
|
|
131
|
+
signal,
|
|
132
|
+
completeSimple = defaultCompleteSimple,
|
|
133
|
+
}: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
|
|
134
|
+
if (signal?.aborted) return { kind: "aborted" };
|
|
135
|
+
let response: AssistantMessage;
|
|
136
|
+
try {
|
|
137
|
+
response = await completeSimple(
|
|
138
|
+
model,
|
|
139
|
+
{ systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
|
|
140
|
+
buildStreamOptions(auth, thinkingLevel, signal),
|
|
141
|
+
);
|
|
142
|
+
} catch (error: unknown) {
|
|
143
|
+
if (signal?.aborted) return { kind: "aborted" };
|
|
144
|
+
return { kind: "error", message: formatError(error) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (signal?.aborted || response.stopReason === "aborted") return { kind: "aborted" };
|
|
148
|
+
if (response.stopReason === "error") {
|
|
149
|
+
return { kind: "error", message: response.errorMessage ?? "The side model returned an error." };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const answer = extractAssistantText(response) || "No response received.";
|
|
153
|
+
thread.turns.push({ kind: "answered", question, answer, response });
|
|
154
|
+
return { kind: "answered", response, answer };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface CompleteSideQuestionOptions {
|
|
158
|
+
model: Model<Api>;
|
|
159
|
+
question: string;
|
|
160
|
+
conversationContext: string;
|
|
161
|
+
thinkingLevel: BtwThinkingLevel;
|
|
162
|
+
auth: SideQuestionAuth;
|
|
163
|
+
signal?: AbortSignal;
|
|
164
|
+
completeSimple?: CompleteSimpleFunction;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function completeSideQuestion({
|
|
168
|
+
model,
|
|
169
|
+
question,
|
|
170
|
+
conversationContext,
|
|
171
|
+
thinkingLevel,
|
|
172
|
+
auth,
|
|
173
|
+
signal,
|
|
174
|
+
completeSimple = defaultCompleteSimple,
|
|
175
|
+
}: CompleteSideQuestionOptions): Promise<AssistantMessage> {
|
|
176
|
+
return completeSimple(
|
|
177
|
+
model,
|
|
178
|
+
{
|
|
179
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
180
|
+
messages: [createUserMessage(buildUserPrompt(question, conversationContext))],
|
|
181
|
+
},
|
|
182
|
+
buildStreamOptions(auth, thinkingLevel, signal),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function extractAssistantText(response: AssistantMessage): string {
|
|
187
|
+
return response.content
|
|
188
|
+
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
189
|
+
.map((content) => content.text)
|
|
190
|
+
.join("\n")
|
|
191
|
+
.trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function buildUserPrompt(question: string, conversationContext: string): string {
|
|
195
|
+
return [
|
|
196
|
+
"Answer this side question without modifying the main conversation.",
|
|
197
|
+
"",
|
|
198
|
+
"<side_question>",
|
|
199
|
+
question,
|
|
200
|
+
"</side_question>",
|
|
201
|
+
"",
|
|
202
|
+
"<conversation_context>",
|
|
203
|
+
conversationContext || "No prior conversation context was available.",
|
|
204
|
+
"</conversation_context>",
|
|
205
|
+
].join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function buildFollowUpPrompt(question: string): string {
|
|
209
|
+
return [
|
|
210
|
+
"Continue the same side conversation.",
|
|
211
|
+
"",
|
|
212
|
+
"<side_question>",
|
|
213
|
+
question,
|
|
214
|
+
"</side_question>",
|
|
215
|
+
].join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function createUserMessage(text: string): UserMessage {
|
|
219
|
+
return {
|
|
220
|
+
role: "user",
|
|
221
|
+
content: [{ type: "text", text }],
|
|
222
|
+
timestamp: Date.now(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildStreamOptions(
|
|
227
|
+
auth: SideQuestionAuth,
|
|
228
|
+
thinkingLevel: BtwThinkingLevel,
|
|
229
|
+
signal?: AbortSignal,
|
|
230
|
+
): SimpleStreamOptions {
|
|
231
|
+
const options: SimpleStreamOptions = {
|
|
232
|
+
apiKey: auth.apiKey,
|
|
233
|
+
headers: auth.headers,
|
|
234
|
+
env: auth.env,
|
|
235
|
+
signal,
|
|
236
|
+
};
|
|
237
|
+
if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
|
|
238
|
+
return options;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatError(error: unknown): string {
|
|
242
|
+
return error instanceof Error ? error.message : String(error);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
|
|
246
|
+
|
|
247
|
+
Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
AssistantMessageComponent,
|
|
4
|
+
getMarkdownTheme,
|
|
5
|
+
type Theme,
|
|
6
|
+
UserMessageComponent,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
type Component,
|
|
10
|
+
CURSOR_MARKER,
|
|
11
|
+
Editor,
|
|
12
|
+
type EditorTheme,
|
|
13
|
+
Key,
|
|
14
|
+
Loader,
|
|
15
|
+
Markdown,
|
|
16
|
+
matchesKey,
|
|
17
|
+
type TUI,
|
|
18
|
+
truncateToWidth,
|
|
19
|
+
visibleWidth,
|
|
20
|
+
} from "@earendil-works/pi-tui";
|
|
21
|
+
import type { SideThreadTurn } from "./side-thread.js";
|
|
22
|
+
|
|
23
|
+
const TRANSCRIPT_CHROME_LINES = 2;
|
|
24
|
+
const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;C\u0007"];
|
|
25
|
+
// Pi renders a spacer above the custom component and a two-line built-in footer below it.
|
|
26
|
+
const RESERVED_APP_LINES = 3;
|
|
27
|
+
|
|
28
|
+
export type TranscriptPagerAction = { kind: "submit"; question: string } | { kind: "close" };
|
|
29
|
+
|
|
30
|
+
export class BtwTranscriptPager implements Component {
|
|
31
|
+
private readonly transcriptComponents: Component[];
|
|
32
|
+
private readonly editor: Editor;
|
|
33
|
+
private scrollOffset = 0;
|
|
34
|
+
private lastContentLineCount = 0;
|
|
35
|
+
private lastViewportHeight = 1;
|
|
36
|
+
private followBottom: boolean;
|
|
37
|
+
private warning: string | undefined;
|
|
38
|
+
private finished = false;
|
|
39
|
+
private isFocused = false;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly tui: TUI,
|
|
43
|
+
private readonly theme: Theme,
|
|
44
|
+
turns: readonly SideThreadTurn[],
|
|
45
|
+
private readonly onAction: (action: TranscriptPagerAction) => void,
|
|
46
|
+
options: { startAtBottom?: boolean } = {},
|
|
47
|
+
) {
|
|
48
|
+
this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
|
|
49
|
+
this.followBottom = options.startAtBottom ?? false;
|
|
50
|
+
const editorTheme: EditorTheme = {
|
|
51
|
+
borderColor: (text) => this.theme.fg("accent", text),
|
|
52
|
+
selectList: {
|
|
53
|
+
selectedPrefix: (text) => this.theme.fg("accent", text),
|
|
54
|
+
selectedText: (text) => this.theme.fg("accent", text),
|
|
55
|
+
description: (text) => this.theme.fg("muted", text),
|
|
56
|
+
scrollInfo: (text) => this.theme.fg("dim", text),
|
|
57
|
+
noMatch: (text) => this.theme.fg("warning", text),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
this.editor = new Editor(this.tui, editorTheme);
|
|
61
|
+
this.editor.onChange = () => {
|
|
62
|
+
this.warning = undefined;
|
|
63
|
+
};
|
|
64
|
+
this.editor.onSubmit = (text) => {
|
|
65
|
+
const question = text.trim();
|
|
66
|
+
if (!question) {
|
|
67
|
+
this.warning = "Question cannot be empty";
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.finished = true;
|
|
71
|
+
this.onAction({ kind: "submit", question });
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get focused(): boolean {
|
|
76
|
+
return this.isFocused;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
set focused(value: boolean) {
|
|
80
|
+
this.isFocused = value;
|
|
81
|
+
this.editor.focused = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
render(width: number): string[] {
|
|
85
|
+
const safeWidth = Math.max(1, width);
|
|
86
|
+
const editorLines = this.editor.render(safeWidth);
|
|
87
|
+
const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
|
|
88
|
+
const viewportHeight = Math.max(
|
|
89
|
+
0,
|
|
90
|
+
availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES,
|
|
91
|
+
);
|
|
92
|
+
const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
|
|
93
|
+
this.lastContentLineCount = contentLines.length;
|
|
94
|
+
this.lastViewportHeight = viewportHeight;
|
|
95
|
+
if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
|
|
96
|
+
this.clampScrollOffset();
|
|
97
|
+
|
|
98
|
+
return fitComposerLayout(
|
|
99
|
+
renderSideThreadHeader(safeWidth, this.theme),
|
|
100
|
+
contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
|
|
101
|
+
this.renderFooter(safeWidth),
|
|
102
|
+
editorLines,
|
|
103
|
+
availableRows,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
handleInput(data: string): void {
|
|
108
|
+
if (this.finished) return;
|
|
109
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
110
|
+
this.finished = true;
|
|
111
|
+
this.onAction({ kind: "close" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
115
|
+
const previousOffset = this.scrollOffset;
|
|
116
|
+
this.scrollBy(-this.lastViewportHeight);
|
|
117
|
+
if (this.scrollOffset < previousOffset) this.followBottom = false;
|
|
118
|
+
this.tui.requestRender();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
122
|
+
this.scrollBy(this.lastViewportHeight);
|
|
123
|
+
this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
|
|
124
|
+
this.tui.requestRender();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.editor.handleInput(data);
|
|
128
|
+
if (!this.finished) this.tui.requestRender();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
invalidate(): void {
|
|
132
|
+
for (const component of this.transcriptComponents) component.invalidate();
|
|
133
|
+
this.editor.invalidate();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private renderFooter(width: number): string {
|
|
137
|
+
if (this.warning) {
|
|
138
|
+
const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C exit`;
|
|
139
|
+
return truncateToWidth(this.theme.fg("warning", warning), width);
|
|
140
|
+
}
|
|
141
|
+
const scrollable = this.getMaxScrollOffset() > 0;
|
|
142
|
+
const fullBase = "btw • Enter send • Ctrl+C exit";
|
|
143
|
+
const compactBase = "btw • Enter • Ctrl+C";
|
|
144
|
+
let hints = visibleWidth(fullBase) <= width ? fullBase : compactBase;
|
|
145
|
+
if (scrollable) {
|
|
146
|
+
const history = ` • ${this.scrollOffset > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
|
|
147
|
+
const compactHistory = " • PgUp/PgDn";
|
|
148
|
+
if (visibleWidth(`${hints}${history}`) <= width) {
|
|
149
|
+
hints += history;
|
|
150
|
+
} else if (visibleWidth(`${hints}${compactHistory}`) <= width) {
|
|
151
|
+
hints += compactHistory;
|
|
152
|
+
} else if (visibleWidth(`${compactBase}${compactHistory}`) <= width) {
|
|
153
|
+
hints = `${compactBase}${compactHistory}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return truncateToWidth(this.theme.fg("muted", hints), width);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private scrollBy(delta: number): void {
|
|
160
|
+
this.scrollOffset += delta;
|
|
161
|
+
this.clampScrollOffset();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private clampScrollOffset(): void {
|
|
165
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private getMaxScrollOffset(): number {
|
|
169
|
+
return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class BtwAnsweringView implements Component {
|
|
174
|
+
private readonly transcriptComponents: Component[];
|
|
175
|
+
private readonly loader: Loader;
|
|
176
|
+
private readonly controller = new AbortController();
|
|
177
|
+
private scrollOffset = 0;
|
|
178
|
+
private lastContentLineCount = 0;
|
|
179
|
+
private lastViewportHeight = 1;
|
|
180
|
+
private followBottom = true;
|
|
181
|
+
private finished = false;
|
|
182
|
+
|
|
183
|
+
constructor(
|
|
184
|
+
private readonly tui: TUI,
|
|
185
|
+
private readonly theme: Theme,
|
|
186
|
+
turns: readonly SideThreadTurn[],
|
|
187
|
+
pendingQuestion: string,
|
|
188
|
+
private readonly onCancel: () => void,
|
|
189
|
+
) {
|
|
190
|
+
this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
|
|
191
|
+
this.loader = new Loader(
|
|
192
|
+
this.tui,
|
|
193
|
+
(text) => this.theme.fg("accent", text),
|
|
194
|
+
(text) => this.theme.fg("muted", text),
|
|
195
|
+
"Answering…",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get signal(): AbortSignal {
|
|
200
|
+
return this.controller.signal;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
render(width: number): string[] {
|
|
204
|
+
const safeWidth = Math.max(1, width);
|
|
205
|
+
const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
|
|
206
|
+
const viewportHeight = Math.max(0, availableRows - TRANSCRIPT_CHROME_LINES);
|
|
207
|
+
const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
|
|
208
|
+
this.lastContentLineCount = contentLines.length;
|
|
209
|
+
this.lastViewportHeight = viewportHeight;
|
|
210
|
+
if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
|
|
211
|
+
this.clampScrollOffset();
|
|
212
|
+
const cancelHint = safeWidth < 28 ? "Ctrl+C" : "Ctrl+C cancel";
|
|
213
|
+
const loaderWidth = Math.max(1, safeWidth - visibleWidth(cancelHint) - 3);
|
|
214
|
+
const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
|
|
215
|
+
const lines = [
|
|
216
|
+
renderSideThreadHeader(safeWidth, this.theme),
|
|
217
|
+
...contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
|
|
218
|
+
truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", cancelHint)}`, safeWidth),
|
|
219
|
+
];
|
|
220
|
+
return fitWithFixedHeader(lines, availableRows);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
handleInput(data: string): void {
|
|
224
|
+
if (this.finished) return;
|
|
225
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
226
|
+
this.finished = true;
|
|
227
|
+
this.loader.stop();
|
|
228
|
+
this.controller.abort();
|
|
229
|
+
this.onCancel();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
233
|
+
const previousOffset = this.scrollOffset;
|
|
234
|
+
this.scrollBy(-this.lastViewportHeight);
|
|
235
|
+
if (this.scrollOffset < previousOffset) this.followBottom = false;
|
|
236
|
+
this.tui.requestRender();
|
|
237
|
+
} else if (matchesKey(data, Key.pageDown)) {
|
|
238
|
+
this.scrollBy(this.lastViewportHeight);
|
|
239
|
+
this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
|
|
240
|
+
this.tui.requestRender();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
invalidate(): void {
|
|
245
|
+
for (const component of this.transcriptComponents) component.invalidate();
|
|
246
|
+
this.loader.invalidate();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
finish(): void {
|
|
250
|
+
this.finished = true;
|
|
251
|
+
this.loader.stop();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
dispose(): void {
|
|
255
|
+
this.finish();
|
|
256
|
+
this.controller.abort();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private scrollBy(delta: number): void {
|
|
260
|
+
this.scrollOffset += delta;
|
|
261
|
+
this.clampScrollOffset();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private clampScrollOffset(): void {
|
|
265
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private getMaxScrollOffset(): number {
|
|
269
|
+
return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function formatSideTranscript(turns: readonly SideThreadTurn[]): string {
|
|
274
|
+
return turns
|
|
275
|
+
.map((turn) => {
|
|
276
|
+
const question = escapeTerminalControls(turn.question);
|
|
277
|
+
const rawAnswer = escapeTerminalControls(turn.answer);
|
|
278
|
+
const answer = turn.kind === "error" ? `Error: ${rawAnswer}` : rawAnswer;
|
|
279
|
+
return `${question}\n\n${answer}`;
|
|
280
|
+
})
|
|
281
|
+
.join("\n\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildTranscriptComponents(
|
|
285
|
+
turns: readonly SideThreadTurn[],
|
|
286
|
+
theme: Theme,
|
|
287
|
+
pendingQuestion?: string,
|
|
288
|
+
): Component[] {
|
|
289
|
+
const components = turns.flatMap((turn): Component[] => {
|
|
290
|
+
const question = new UserMessageComponent(
|
|
291
|
+
escapeTerminalControls(turn.question),
|
|
292
|
+
getMarkdownTheme(),
|
|
293
|
+
1,
|
|
294
|
+
);
|
|
295
|
+
if (turn.kind === "error") {
|
|
296
|
+
const error = new Markdown(
|
|
297
|
+
`Error: ${escapeTerminalControls(turn.answer)}`,
|
|
298
|
+
1,
|
|
299
|
+
1,
|
|
300
|
+
getMarkdownTheme(),
|
|
301
|
+
{ color: (text) => theme.fg("error", text) },
|
|
302
|
+
);
|
|
303
|
+
return [question, error];
|
|
304
|
+
}
|
|
305
|
+
const response: AssistantMessage = {
|
|
306
|
+
...turn.response,
|
|
307
|
+
content: [{ type: "text", text: escapeTerminalControls(turn.answer) }],
|
|
308
|
+
stopReason: "stop",
|
|
309
|
+
errorMessage: undefined,
|
|
310
|
+
};
|
|
311
|
+
return [question, new AssistantMessageComponent(response, true, getMarkdownTheme(), "", 1)];
|
|
312
|
+
});
|
|
313
|
+
if (pendingQuestion) {
|
|
314
|
+
components.push(
|
|
315
|
+
new UserMessageComponent(escapeTerminalControls(pendingQuestion), getMarkdownTheme(), 1),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return components;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function renderTranscriptLines(components: readonly Component[], width: number): string[] {
|
|
322
|
+
return components
|
|
323
|
+
.flatMap((component) => component.render(width))
|
|
324
|
+
.map(stripShellIntegrationMarkers);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderSideThreadHeader(width: number, theme: Theme): string {
|
|
328
|
+
const title = truncateToWidth("─ btw · side thread ", width);
|
|
329
|
+
const ruleWidth = Math.max(0, width - visibleWidth(title));
|
|
330
|
+
return theme.fg("muted", `${title}${"─".repeat(ruleWidth)}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function fitComposerLayout(
|
|
334
|
+
header: string,
|
|
335
|
+
contentLines: string[],
|
|
336
|
+
footer: string,
|
|
337
|
+
editorLines: string[],
|
|
338
|
+
availableRows: number,
|
|
339
|
+
): string[] {
|
|
340
|
+
const lines = [header, ...contentLines, footer, ...editorLines];
|
|
341
|
+
if (lines.length <= availableRows) return lines;
|
|
342
|
+
if (availableRows <= 1) return [header];
|
|
343
|
+
const editorBudget = Math.max(0, availableRows - 2);
|
|
344
|
+
return [header, footer, ...fitEditorLines(editorLines, editorBudget)];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function fitEditorLines(editorLines: string[], budget: number): string[] {
|
|
348
|
+
if (budget <= 0) return [];
|
|
349
|
+
if (editorLines.length <= budget) return editorLines;
|
|
350
|
+
const cursorIndex = editorLines.findIndex((line) => line.includes(CURSOR_MARKER));
|
|
351
|
+
if (cursorIndex < 0) return editorLines.slice(-budget);
|
|
352
|
+
const start = Math.min(cursorIndex, editorLines.length - budget);
|
|
353
|
+
return editorLines.slice(start, start + budget);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function fitWithFixedHeader(lines: string[], availableRows: number): string[] {
|
|
357
|
+
if (lines.length <= availableRows) return lines;
|
|
358
|
+
if (availableRows <= 1) return lines.slice(0, 1);
|
|
359
|
+
return [lines[0] ?? "", ...lines.slice(lines.length - availableRows + 1)];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function stripShellIntegrationMarkers(line: string): string {
|
|
363
|
+
return OSC133_MARKERS.reduce((result, marker) => result.replaceAll(marker, ""), line);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function escapeTerminalControls(text: string): string {
|
|
367
|
+
return [...text]
|
|
368
|
+
.map((character) => {
|
|
369
|
+
if (character === "\n") return character;
|
|
370
|
+
if (character === "\t") return " ";
|
|
371
|
+
const code = character.charCodeAt(0);
|
|
372
|
+
if (code <= 31 || (code >= 127 && code <= 159)) {
|
|
373
|
+
return `\\x${code.toString(16).padStart(2, "0")}`;
|
|
374
|
+
}
|
|
375
|
+
return character;
|
|
376
|
+
})
|
|
377
|
+
.join("");
|
|
378
|
+
}
|