@narumitw/pi-btw 0.20.0 → 0.28.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 +20 -6
- package/package.json +2 -2
- package/src/btw.ts +172 -386
- package/src/index.ts +1 -0
- package/src/side-thread.ts +247 -0
- package/src/transcript-pager.ts +378 -0
package/src/btw.ts
CHANGED
|
@@ -1,84 +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
|
-
"max",
|
|
79
|
-
] as const;
|
|
80
|
-
|
|
81
|
-
export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
|
|
82
34
|
|
|
83
35
|
export interface BtwSettings {
|
|
84
36
|
model?: string;
|
|
@@ -95,15 +47,11 @@ interface LoadBtwThinkingLevelOptions {
|
|
|
95
47
|
warn?: (message: string) => void;
|
|
96
48
|
}
|
|
97
49
|
|
|
98
|
-
interface SideQuestionAuth {
|
|
99
|
-
apiKey?: string;
|
|
100
|
-
headers?: Record<string, string>;
|
|
101
|
-
env?: Record<string, string>;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
50
|
interface BtwModelRegistry {
|
|
105
51
|
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
106
|
-
getApiKeyAndHeaders(
|
|
52
|
+
getApiKeyAndHeaders(
|
|
53
|
+
model: Model<Api>,
|
|
54
|
+
): Promise<
|
|
107
55
|
| { ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
|
|
108
56
|
| { ok: false; error: string }
|
|
109
57
|
>;
|
|
@@ -116,21 +64,11 @@ interface ResolveBtwModelOptions {
|
|
|
116
64
|
warn?: (message: string) => void;
|
|
117
65
|
}
|
|
118
66
|
|
|
119
|
-
interface ResolvedBtwModel {
|
|
67
|
+
export interface ResolvedBtwModel {
|
|
120
68
|
model: Model<Api>;
|
|
121
69
|
auth: SideQuestionAuth;
|
|
122
70
|
}
|
|
123
71
|
|
|
124
|
-
interface CompleteSideQuestionOptions {
|
|
125
|
-
model: Model<Api>;
|
|
126
|
-
question: string;
|
|
127
|
-
conversationContext: string;
|
|
128
|
-
thinkingLevel: BtwThinkingLevel;
|
|
129
|
-
auth: SideQuestionAuth;
|
|
130
|
-
signal?: AbortSignal;
|
|
131
|
-
completeSimple?: CompleteSimpleFunction;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
72
|
export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
|
|
135
73
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
136
74
|
|
|
@@ -167,14 +105,19 @@ export async function resolveBtwModel({
|
|
|
167
105
|
const fallback = currentModel
|
|
168
106
|
? `${currentModel.provider}/${currentModel.id}`
|
|
169
107
|
: "the current model";
|
|
170
|
-
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
|
+
}
|
|
171
113
|
const configuredModel = modelRegistry.find(reference.provider, reference.modelId);
|
|
172
114
|
if (!configuredModel) {
|
|
173
115
|
warn?.(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
|
|
174
116
|
} else {
|
|
175
117
|
const sameAsCurrent =
|
|
176
118
|
configuredModel === currentModel ||
|
|
177
|
-
(configuredModel.provider === currentModel?.provider &&
|
|
119
|
+
(configuredModel.provider === currentModel?.provider &&
|
|
120
|
+
configuredModel.id === currentModel.id);
|
|
178
121
|
const fallbackAction = sameAsCurrent
|
|
179
122
|
? "no distinct current model is available"
|
|
180
123
|
: `falling back to ${fallback}`;
|
|
@@ -182,9 +125,7 @@ export async function resolveBtwModel({
|
|
|
182
125
|
const auth = await modelRegistry.getApiKeyAndHeaders(configuredModel);
|
|
183
126
|
if (auth.ok && hasRequestAuth(auth)) return { model: configuredModel, auth };
|
|
184
127
|
const reason = auth.ok ? "has no request credentials" : auth.error;
|
|
185
|
-
warn?.(
|
|
186
|
-
`pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`,
|
|
187
|
-
);
|
|
128
|
+
warn?.(`pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`);
|
|
188
129
|
} catch (error: unknown) {
|
|
189
130
|
warn?.(
|
|
190
131
|
`pi-btw model ${settings.model} credentials failed (${formatError(error)}); ${fallbackAction}.`,
|
|
@@ -248,38 +189,6 @@ export async function loadBtwThinkingLevel(
|
|
|
248
189
|
return currentThinkingLevel;
|
|
249
190
|
}
|
|
250
191
|
|
|
251
|
-
export async function completeSideQuestion({
|
|
252
|
-
model,
|
|
253
|
-
question,
|
|
254
|
-
conversationContext,
|
|
255
|
-
thinkingLevel,
|
|
256
|
-
auth,
|
|
257
|
-
signal,
|
|
258
|
-
completeSimple: runCompleteSimple = completeSimple,
|
|
259
|
-
}: CompleteSideQuestionOptions): Promise<AssistantMessage> {
|
|
260
|
-
const userMessage: UserMessage = {
|
|
261
|
-
role: "user",
|
|
262
|
-
content: [
|
|
263
|
-
{
|
|
264
|
-
type: "text",
|
|
265
|
-
text: buildUserPrompt(question, conversationContext),
|
|
266
|
-
},
|
|
267
|
-
],
|
|
268
|
-
timestamp: Date.now(),
|
|
269
|
-
};
|
|
270
|
-
const streamOptions: SimpleStreamOptions = {
|
|
271
|
-
apiKey: auth.apiKey,
|
|
272
|
-
headers: auth.headers,
|
|
273
|
-
env: auth.env,
|
|
274
|
-
signal,
|
|
275
|
-
};
|
|
276
|
-
if (thinkingLevel !== "off") {
|
|
277
|
-
(streamOptions as unknown as { reasoning?: BtwThinkingLevel }).reasoning = thinkingLevel;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
return runCompleteSimple(model, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, streamOptions);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
192
|
function isBtwThinkingLevel(value: unknown): value is BtwThinkingLevel {
|
|
284
193
|
return BTW_THINKING_LEVELS.includes(value as BtwThinkingLevel);
|
|
285
194
|
}
|
|
@@ -292,52 +201,17 @@ function formatError(error: unknown): string {
|
|
|
292
201
|
return error instanceof Error ? error.message : String(error);
|
|
293
202
|
}
|
|
294
203
|
|
|
295
|
-
const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
|
|
296
|
-
|
|
297
|
-
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.`;
|
|
298
|
-
|
|
299
|
-
type MessageContentBlock = {
|
|
300
|
-
type?: string;
|
|
301
|
-
text?: string;
|
|
302
|
-
name?: string;
|
|
303
|
-
arguments?: unknown;
|
|
304
|
-
result?: unknown;
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
type SessionMessage = {
|
|
308
|
-
role?: string;
|
|
309
|
-
content?: unknown;
|
|
310
|
-
stopReason?: string;
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
type SessionEntry = {
|
|
314
|
-
type: string;
|
|
315
|
-
message?: SessionMessage;
|
|
316
|
-
};
|
|
317
|
-
|
|
318
204
|
export default function btw(pi: ExtensionAPI) {
|
|
319
205
|
pi.registerCommand("btw", {
|
|
320
206
|
description: "Ask a quick side question without adding it to the main conversation",
|
|
321
207
|
handler: async (args, ctx) => {
|
|
322
208
|
const question = args.trim();
|
|
323
|
-
if (
|
|
324
|
-
ctx.ui.notify("
|
|
209
|
+
if (ctx.mode !== "tui") {
|
|
210
|
+
ctx.ui.notify("/btw requires interactive TUI mode", "error");
|
|
325
211
|
return;
|
|
326
212
|
}
|
|
327
213
|
|
|
328
|
-
|
|
329
|
-
ctx.ui.notify("/btw requires interactive mode", "error");
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const settingsResult = await readBtwSettings();
|
|
334
|
-
let settings: BtwSettings = {};
|
|
335
|
-
if (settingsResult.kind === "loaded") {
|
|
336
|
-
settings = settingsResult.settings;
|
|
337
|
-
} else if (settingsResult.kind === "invalid") {
|
|
338
|
-
ctx.ui.notify(`pi-btw settings ignored: ${settingsResult.reason}`, "warning");
|
|
339
|
-
}
|
|
340
|
-
|
|
214
|
+
const settings = await loadSettingsForCommand(ctx);
|
|
341
215
|
const resolution = await resolveBtwModelWithLoader(settings, ctx);
|
|
342
216
|
if (resolution.kind === "cancelled") {
|
|
343
217
|
ctx.ui.notify("Cancelled", "info");
|
|
@@ -348,18 +222,25 @@ export default function btw(pi: ExtensionAPI) {
|
|
|
348
222
|
return;
|
|
349
223
|
}
|
|
350
224
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
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
|
+
});
|
|
359
231
|
},
|
|
360
232
|
});
|
|
361
233
|
}
|
|
362
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
|
+
|
|
363
244
|
type ModelResolutionOutcome =
|
|
364
245
|
| { kind: "cancelled" }
|
|
365
246
|
| { kind: "unavailable" }
|
|
@@ -371,9 +252,10 @@ async function resolveBtwModelWithLoader(
|
|
|
371
252
|
): Promise<ModelResolutionOutcome> {
|
|
372
253
|
return ctx.ui.custom<ModelResolutionOutcome>((tui, theme, _keybindings, done) => {
|
|
373
254
|
const loader = new BorderedLoader(tui, theme, "Resolving /btw model credentials...");
|
|
374
|
-
let
|
|
255
|
+
let settled = false;
|
|
375
256
|
loader.onAbort = () => {
|
|
376
|
-
|
|
257
|
+
if (settled) return;
|
|
258
|
+
settled = true;
|
|
377
259
|
done({ kind: "cancelled" });
|
|
378
260
|
};
|
|
379
261
|
|
|
@@ -382,235 +264,146 @@ async function resolveBtwModelWithLoader(
|
|
|
382
264
|
currentModel: ctx.model,
|
|
383
265
|
modelRegistry: ctx.modelRegistry,
|
|
384
266
|
warn: (message) => {
|
|
385
|
-
if (!
|
|
267
|
+
if (!settled) ctx.ui.notify(message, "warning");
|
|
386
268
|
},
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
question: string,
|
|
398
|
-
selected: ResolvedBtwModel,
|
|
399
|
-
thinkingLevel: BtwThinkingLevel,
|
|
400
|
-
ctx: ExtensionCommandContext,
|
|
401
|
-
): Promise<string | undefined> {
|
|
402
|
-
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
403
|
-
const loader = new BorderedLoader(
|
|
404
|
-
tui,
|
|
405
|
-
theme,
|
|
406
|
-
`Answering /btw with ${selected.model.provider}/${selected.model.id}...`,
|
|
407
|
-
);
|
|
408
|
-
loader.onAbort = () => done(undefined);
|
|
409
|
-
|
|
410
|
-
const ask = async () => {
|
|
411
|
-
const conversationContext = buildConversationContext(ctx.sessionManager.getBranch());
|
|
412
|
-
const response = await completeSideQuestion({
|
|
413
|
-
model: selected.model,
|
|
414
|
-
question,
|
|
415
|
-
conversationContext,
|
|
416
|
-
thinkingLevel,
|
|
417
|
-
auth: selected.auth,
|
|
418
|
-
signal: loader.signal,
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
if (response.stopReason === "aborted") {
|
|
422
|
-
return undefined;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const text = response.content
|
|
426
|
-
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
427
|
-
.map((content) => content.text)
|
|
428
|
-
.join("\n")
|
|
429
|
-
.trim();
|
|
430
|
-
|
|
431
|
-
return text || "No response received.";
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
ask()
|
|
435
|
-
.then(done)
|
|
436
|
-
.catch((error: unknown) => {
|
|
437
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
438
|
-
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" });
|
|
439
279
|
});
|
|
440
280
|
|
|
441
281
|
return loader;
|
|
442
282
|
});
|
|
443
283
|
}
|
|
444
284
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
});
|
|
285
|
+
interface RunBtwThreadDependencies {
|
|
286
|
+
ask?: typeof askThreadQuestion;
|
|
287
|
+
interact?: typeof showThreadComposer;
|
|
449
288
|
}
|
|
450
289
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
private readonly markdown: Markdown;
|
|
459
|
-
private scrollOffset = 0;
|
|
460
|
-
private lastContentLineCount = 0;
|
|
461
|
-
private lastViewportHeight = 1;
|
|
462
|
-
|
|
463
|
-
constructor(tui: TUI, theme: Theme, question: string, answer: string, onClose: () => void) {
|
|
464
|
-
this.tui = tui;
|
|
465
|
-
this.theme = theme;
|
|
466
|
-
this.title = sanitizeSingleLine(`/btw ${question}`);
|
|
467
|
-
this.onClose = onClose;
|
|
468
|
-
const borderColor = (text: string) => this.theme.fg("warning", text);
|
|
469
|
-
this.topBorder = new DynamicBorder(borderColor);
|
|
470
|
-
this.bottomBorder = new DynamicBorder(borderColor);
|
|
471
|
-
this.markdown = new Markdown(answer, 1, 1, getMarkdownTheme());
|
|
472
|
-
}
|
|
290
|
+
interface RunBtwThreadOptions {
|
|
291
|
+
initialQuestion?: string;
|
|
292
|
+
selected: ResolvedBtwModel;
|
|
293
|
+
thinkingLevel: BtwThinkingLevel;
|
|
294
|
+
ctx: ExtensionCommandContext;
|
|
295
|
+
dependencies?: RunBtwThreadDependencies;
|
|
296
|
+
}
|
|
473
297
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
];
|
|
493
|
-
}
|
|
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
|
+
}
|
|
494
316
|
|
|
495
|
-
|
|
496
|
-
if (
|
|
497
|
-
|
|
317
|
+
const result = await ask(thread, pendingQuestion, selected, thinkingLevel, ctx);
|
|
318
|
+
if (result.kind === "aborted") {
|
|
319
|
+
ctx.ui.notify("Cancelled", "info");
|
|
498
320
|
return;
|
|
499
321
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
matchesKey(data, Key.pageUp) ||
|
|
507
|
-
matchesKey(data, Key.shift(Key.space)) ||
|
|
508
|
-
matchesKey(data, Key.ctrl("b"))
|
|
509
|
-
) {
|
|
510
|
-
this.scrollBy(-this.lastViewportHeight);
|
|
511
|
-
} else if (
|
|
512
|
-
matchesKey(data, Key.pageDown) ||
|
|
513
|
-
matchesKey(data, Key.space) ||
|
|
514
|
-
matchesKey(data, Key.ctrl("f"))
|
|
515
|
-
) {
|
|
516
|
-
this.scrollBy(this.lastViewportHeight);
|
|
517
|
-
} else if (matchesKey(data, Key.ctrl("u"))) {
|
|
518
|
-
this.scrollBy(-this.getHalfPageHeight());
|
|
519
|
-
} else if (matchesKey(data, Key.ctrl("d"))) {
|
|
520
|
-
this.scrollBy(this.getHalfPageHeight());
|
|
521
|
-
} else if (matchesKey(data, Key.home)) {
|
|
522
|
-
this.scrollOffset = 0;
|
|
523
|
-
} else if (matchesKey(data, Key.end)) {
|
|
524
|
-
this.scrollOffset = this.getMaxScrollOffset();
|
|
322
|
+
if (result.kind === "error") {
|
|
323
|
+
thread.turns.push({
|
|
324
|
+
kind: "error",
|
|
325
|
+
question: pendingQuestion,
|
|
326
|
+
answer: result.message,
|
|
327
|
+
});
|
|
525
328
|
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
invalidate(): void {
|
|
529
|
-
this.topBorder.invalidate();
|
|
530
|
-
this.bottomBorder.invalidate();
|
|
531
|
-
this.markdown.invalidate();
|
|
532
|
-
}
|
|
533
329
|
|
|
534
|
-
|
|
535
|
-
return (
|
|
536
|
-
matchesKey(data, "q") ||
|
|
537
|
-
matchesKey(data, Key.escape) ||
|
|
538
|
-
matchesKey(data, Key.enter) ||
|
|
539
|
-
matchesKey(data, Key.return) ||
|
|
540
|
-
matchesKey(data, Key.ctrl("c"))
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
private renderTitle(width: number): string {
|
|
545
|
-
return truncateToWidth(this.theme.fg("warning", this.theme.bold(this.title)), width);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
private renderFooter(width: number): string {
|
|
549
|
-
const progress = this.formatProgress();
|
|
550
|
-
const hints = "↑↓/j/k scroll • PgUp/PgDn page • Home/End jump • q/Esc close";
|
|
551
|
-
const progressWidth = visibleWidth(progress);
|
|
552
|
-
const footer =
|
|
553
|
-
progressWidth + 3 >= width
|
|
554
|
-
? truncateToWidth(progress, width)
|
|
555
|
-
: `${truncateToWidth(hints, width - progressWidth - 3)} • ${progress}`;
|
|
556
|
-
return this.theme.fg("dim", footer);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
private formatProgress(): string {
|
|
560
|
-
const total = this.lastContentLineCount;
|
|
561
|
-
if (total === 0) return "100% 0-0/0";
|
|
562
|
-
|
|
563
|
-
const maxScroll = this.getMaxScrollOffset();
|
|
564
|
-
const percent = maxScroll === 0 ? 100 : Math.round((this.scrollOffset / maxScroll) * 100);
|
|
565
|
-
const firstLine = this.scrollOffset + 1;
|
|
566
|
-
const lastLine = Math.min(total, this.scrollOffset + this.lastViewportHeight);
|
|
567
|
-
|
|
568
|
-
return `${percent}% ${firstLine}-${lastLine}/${total}`;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
private scrollBy(delta: number): void {
|
|
572
|
-
this.scrollOffset += delta;
|
|
573
|
-
this.clampScrollOffset();
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
private clampScrollOffset(): void {
|
|
577
|
-
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
private getMaxScrollOffset(): number {
|
|
581
|
-
return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
|
|
330
|
+
pendingQuestion = undefined;
|
|
582
331
|
}
|
|
332
|
+
}
|
|
583
333
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
+
}
|
|
587
366
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
+
);
|
|
591
376
|
}
|
|
592
377
|
|
|
593
378
|
export function sanitizeSingleLine(text: string) {
|
|
594
|
-
return text
|
|
595
|
-
.
|
|
596
|
-
|
|
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("")
|
|
597
385
|
.replace(/ +/g, " ")
|
|
598
386
|
.trim();
|
|
599
387
|
}
|
|
600
388
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
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
|
+
};
|
|
614
407
|
|
|
615
408
|
export function buildConversationContext(entries: readonly SessionEntry[]) {
|
|
616
409
|
const sections: string[] = [];
|
|
@@ -636,18 +429,12 @@ export function buildConversationContext(entries: readonly SessionEntry[]) {
|
|
|
636
429
|
}
|
|
637
430
|
|
|
638
431
|
function extractContentLines(content: unknown): string[] {
|
|
639
|
-
if (typeof content === "string")
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
if (!Array.isArray(content)) {
|
|
644
|
-
return [];
|
|
645
|
-
}
|
|
432
|
+
if (typeof content === "string") return [content.trim()].filter(Boolean);
|
|
433
|
+
if (!Array.isArray(content)) return [];
|
|
646
434
|
|
|
647
435
|
const lines: string[] = [];
|
|
648
436
|
for (const part of content) {
|
|
649
437
|
if (!part || typeof part !== "object") continue;
|
|
650
|
-
|
|
651
438
|
const block = part as MessageContentBlock;
|
|
652
439
|
if (block.type === "text" && typeof block.text === "string") {
|
|
653
440
|
lines.push(block.text.trim());
|
|
@@ -657,7 +444,6 @@ function extractContentLines(content: unknown): string[] {
|
|
|
657
444
|
lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
|
|
658
445
|
}
|
|
659
446
|
}
|
|
660
|
-
|
|
661
447
|
return lines.filter(Boolean);
|
|
662
448
|
}
|
|
663
449
|
|