pi-btw 0.1.1 → 0.2.1
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 +40 -6
- package/docs/btw-overlay.png +0 -0
- package/extensions/btw.ts +1557 -300
- package/package.json +8 -2
package/extensions/btw.ts
CHANGED
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildSessionContext,
|
|
3
|
+
createAgentSession,
|
|
4
|
+
createExtensionRuntime,
|
|
5
|
+
codingTools,
|
|
6
|
+
SessionManager,
|
|
7
|
+
type AgentSession,
|
|
8
|
+
type AgentSessionEvent,
|
|
3
9
|
type ExtensionAPI,
|
|
4
10
|
type ExtensionCommandContext,
|
|
5
11
|
type ExtensionContext,
|
|
12
|
+
type ResourceLoader,
|
|
6
13
|
} from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel } from "@mariozechner/pi-ai";
|
|
7
15
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
Box,
|
|
17
|
+
Container,
|
|
18
|
+
Input,
|
|
19
|
+
Key,
|
|
20
|
+
Text,
|
|
21
|
+
matchesKey,
|
|
22
|
+
truncateToWidth,
|
|
23
|
+
visibleWidth,
|
|
24
|
+
wrapTextWithAnsi,
|
|
25
|
+
type Focusable,
|
|
26
|
+
type KeybindingsManager,
|
|
27
|
+
type OverlayHandle,
|
|
28
|
+
type TUI,
|
|
29
|
+
} from "@mariozechner/pi-tui";
|
|
15
30
|
|
|
16
31
|
const BTW_MESSAGE_TYPE = "btw-note";
|
|
17
32
|
const BTW_ENTRY_TYPE = "btw-thread-entry";
|
|
18
33
|
const BTW_RESET_TYPE = "btw-thread-reset";
|
|
34
|
+
const BTW_FOCUS_SHORTCUTS = [Key.alt("/"), Key.ctrlAlt("w")] as const;
|
|
35
|
+
|
|
36
|
+
function matchesBtwFocusShortcut(data: string): boolean {
|
|
37
|
+
return BTW_FOCUS_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut));
|
|
38
|
+
}
|
|
19
39
|
|
|
20
40
|
const BTW_SYSTEM_PROMPT = [
|
|
21
41
|
"You are having an aside conversation with the user, separate from their main working session.",
|
|
@@ -25,6 +45,12 @@ const BTW_SYSTEM_PROMPT = [
|
|
|
25
45
|
"Do not act as if you need to continue unfinished work from the main session unless the user explicitly asks you to prepare something for injection back to it.",
|
|
26
46
|
].join(" ");
|
|
27
47
|
|
|
48
|
+
const BTW_SUMMARIZE_SYSTEM_PROMPT =
|
|
49
|
+
"Summarize the side conversation concisely. Preserve key decisions, plans, insights, risks, and action items. Output only the summary.";
|
|
50
|
+
|
|
51
|
+
const BTW_CONTINUE_THREAD_USER_TEXT = "[The following is a separate side conversation. Continue this thread.]";
|
|
52
|
+
const BTW_CONTINUE_THREAD_ASSISTANT_TEXT = "Understood, continuing our side conversation.";
|
|
53
|
+
|
|
28
54
|
type SessionThinkingLevel = "off" | AiThinkingLevel;
|
|
29
55
|
type BtwThreadMode = "contextual" | "tangent";
|
|
30
56
|
|
|
@@ -51,13 +77,49 @@ type BtwResetDetails = {
|
|
|
51
77
|
mode?: BtwThreadMode;
|
|
52
78
|
};
|
|
53
79
|
|
|
54
|
-
type
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
thinking: string;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
80
|
+
type BtwTranscriptEntry =
|
|
81
|
+
| { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end" }
|
|
82
|
+
| { id: number; turnId: number; type: "user-message"; text: string }
|
|
83
|
+
| { id: number; turnId: number; type: "thinking"; text: string; streaming: boolean }
|
|
84
|
+
| { id: number; turnId: number; type: "assistant-text"; text: string; streaming: boolean }
|
|
85
|
+
| { id: number; turnId: number; type: "tool-call"; toolCallId: string; toolName: string; args: string }
|
|
86
|
+
| {
|
|
87
|
+
id: number;
|
|
88
|
+
turnId: number;
|
|
89
|
+
type: "tool-result";
|
|
90
|
+
toolCallId: string;
|
|
91
|
+
toolName: string;
|
|
92
|
+
content: string;
|
|
93
|
+
truncated: boolean;
|
|
94
|
+
isError: boolean;
|
|
95
|
+
streaming: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type BtwTranscript = BtwTranscriptEntry[];
|
|
99
|
+
|
|
100
|
+
type BtwTranscriptState = {
|
|
101
|
+
entries: BtwTranscript;
|
|
102
|
+
nextEntryId: number;
|
|
103
|
+
nextTurnId: number;
|
|
104
|
+
currentTurnId: number | null;
|
|
105
|
+
lastTurnId: number | null;
|
|
106
|
+
toolCalls: Map<string, { turnId: number; callEntryId: number; resultEntryId?: number }>;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type BtwSessionRuntime = {
|
|
110
|
+
session: AgentSession;
|
|
111
|
+
mode: BtwThreadMode;
|
|
112
|
+
subscriptions: Set<() => void>;
|
|
113
|
+
sideThreadStartIndex: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
type OverlayRuntime = {
|
|
117
|
+
handle?: OverlayHandle;
|
|
118
|
+
refresh?: () => void;
|
|
119
|
+
close?: () => void;
|
|
120
|
+
finish?: () => void;
|
|
121
|
+
setDraft?: (value: string) => void;
|
|
122
|
+
closed?: boolean;
|
|
61
123
|
};
|
|
62
124
|
|
|
63
125
|
function isVisibleBtwMessage(message: { role: string; customType?: string }): boolean {
|
|
@@ -68,8 +130,32 @@ function isCustomEntry(entry: unknown, customType: string): entry is { type: "cu
|
|
|
68
130
|
return !!entry && typeof entry === "object" && (entry as { type?: string }).type === "custom" && (entry as { customType?: string }).customType === customType;
|
|
69
131
|
}
|
|
70
132
|
|
|
71
|
-
function
|
|
72
|
-
return
|
|
133
|
+
function stripDynamicSystemPromptFooter(systemPrompt: string): string {
|
|
134
|
+
return systemPrompt
|
|
135
|
+
.replace(/\nCurrent date and time:[^\n]*(?:\nCurrent working directory:[^\n]*)?$/u, "")
|
|
136
|
+
.replace(/\nCurrent working directory:[^\n]*$/u, "")
|
|
137
|
+
.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function createBtwResourceLoader(
|
|
141
|
+
ctx: ExtensionCommandContext,
|
|
142
|
+
appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT],
|
|
143
|
+
): ResourceLoader {
|
|
144
|
+
const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
|
|
145
|
+
const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
getExtensions: () => extensionsResult,
|
|
149
|
+
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
150
|
+
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
151
|
+
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
152
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
153
|
+
getSystemPrompt: () => systemPrompt,
|
|
154
|
+
getAppendSystemPrompt: () => appendSystemPrompt,
|
|
155
|
+
getPathMetadata: () => new Map(),
|
|
156
|
+
extendResources: () => {},
|
|
157
|
+
reload: async () => {},
|
|
158
|
+
};
|
|
73
159
|
}
|
|
74
160
|
|
|
75
161
|
function extractText(parts: AssistantMessage["content"], type: "text" | "thinking"): string {
|
|
@@ -100,29 +186,50 @@ function parseBtwArgs(args: string): ParsedBtwArgs {
|
|
|
100
186
|
return { question, save };
|
|
101
187
|
}
|
|
102
188
|
|
|
103
|
-
function
|
|
104
|
-
const sessionContext = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
|
|
105
|
-
return sessionContext.messages.filter((message) => !isVisibleBtwMessage(message));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function buildBtwContext(
|
|
189
|
+
function buildBtwSeedState(
|
|
109
190
|
ctx: ExtensionCommandContext,
|
|
110
|
-
question: string,
|
|
111
191
|
thread: BtwDetails[],
|
|
112
192
|
mode: BtwThreadMode,
|
|
113
|
-
) {
|
|
114
|
-
const messages: Message[] =
|
|
193
|
+
): { messages: Message[]; sideThreadStartIndex: number } {
|
|
194
|
+
const messages: Message[] = [];
|
|
195
|
+
|
|
196
|
+
if (mode === "contextual") {
|
|
197
|
+
try {
|
|
198
|
+
messages.push(
|
|
199
|
+
...buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages.filter(
|
|
200
|
+
(message) => !isVisibleBtwMessage(message),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
} catch {
|
|
204
|
+
messages.push(
|
|
205
|
+
...ctx.sessionManager.getEntries().flatMap((entry) => {
|
|
206
|
+
if (!entry || typeof entry !== "object") {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const message = entry as Partial<Message> & { role?: string; customType?: string; content?: unknown };
|
|
211
|
+
if (typeof message.role !== "string" || !Array.isArray(message.content)) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return isVisibleBtwMessage({ role: message.role, customType: message.customType }) ? [] : [message as Message];
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const sideThreadStartIndex = messages.length;
|
|
115
222
|
|
|
116
223
|
if (thread.length > 0) {
|
|
117
224
|
messages.push(
|
|
118
225
|
{
|
|
119
226
|
role: "user",
|
|
120
|
-
content: [{ type: "text", text:
|
|
227
|
+
content: [{ type: "text", text: BTW_CONTINUE_THREAD_USER_TEXT }],
|
|
121
228
|
timestamp: Date.now(),
|
|
122
229
|
},
|
|
123
230
|
{
|
|
124
231
|
role: "assistant",
|
|
125
|
-
content: [{ type: "text", text:
|
|
232
|
+
content: [{ type: "text", text: BTW_CONTINUE_THREAD_ASSISTANT_TEXT }],
|
|
126
233
|
provider: ctx.model?.provider ?? "unknown",
|
|
127
234
|
model: ctx.model?.id ?? "unknown",
|
|
128
235
|
api: ctx.model?.api ?? "openai-responses",
|
|
@@ -168,159 +275,1366 @@ function buildBtwContext(
|
|
|
168
275
|
}
|
|
169
276
|
}
|
|
170
277
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
278
|
+
return {
|
|
279
|
+
messages,
|
|
280
|
+
sideThreadStartIndex,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatToolPreview(value: unknown): string {
|
|
285
|
+
if (value === undefined) {
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (typeof value === "string") {
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (value && typeof value === "object") {
|
|
294
|
+
const path = (value as { path?: unknown }).path;
|
|
295
|
+
if (typeof path === "string") {
|
|
296
|
+
return path;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const preview = JSON.stringify(value);
|
|
302
|
+
if (!preview || preview === "{}") {
|
|
303
|
+
return "";
|
|
304
|
+
}
|
|
305
|
+
return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview;
|
|
306
|
+
} catch {
|
|
307
|
+
return "";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function createEmptyTranscriptState(): BtwTranscriptState {
|
|
312
|
+
return {
|
|
313
|
+
entries: [],
|
|
314
|
+
nextEntryId: 1,
|
|
315
|
+
nextTurnId: 1,
|
|
316
|
+
currentTurnId: null,
|
|
317
|
+
lastTurnId: null,
|
|
318
|
+
toolCalls: new Map(),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function appendTranscriptEntry<T extends BtwTranscriptEntry>(
|
|
323
|
+
state: BtwTranscriptState,
|
|
324
|
+
entry: Omit<T, "id">,
|
|
325
|
+
): T {
|
|
326
|
+
const nextEntry = { ...entry, id: state.nextEntryId++ } as T;
|
|
327
|
+
state.entries.push(nextEntry);
|
|
328
|
+
return nextEntry;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function ensureTranscriptTurn(state: BtwTranscriptState): number {
|
|
332
|
+
if (state.currentTurnId !== null) {
|
|
333
|
+
return state.currentTurnId;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const turnId = state.nextTurnId++;
|
|
337
|
+
state.currentTurnId = turnId;
|
|
338
|
+
state.lastTurnId = turnId;
|
|
339
|
+
appendTranscriptEntry(state, { type: "turn-boundary", turnId, phase: "start" });
|
|
340
|
+
return turnId;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null): void {
|
|
344
|
+
const resolvedTurnId = turnId ?? state.currentTurnId;
|
|
345
|
+
if (resolvedTurnId === null || resolvedTurnId === undefined) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const hasEndBoundary = state.entries.some(
|
|
350
|
+
(entry) => entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
|
|
351
|
+
);
|
|
352
|
+
if (!hasEndBoundary) {
|
|
353
|
+
appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const entry of state.entries) {
|
|
357
|
+
if (entry.turnId !== resolvedTurnId) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") {
|
|
362
|
+
entry.streaming = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
state.lastTurnId = resolvedTurnId;
|
|
367
|
+
if (state.currentTurnId === resolvedTurnId) {
|
|
368
|
+
state.currentTurnId = null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function removeTranscriptTurn(state: BtwTranscriptState, turnId: number | null): void {
|
|
373
|
+
if (turnId === null) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
state.entries = state.entries.filter((entry) => entry.turnId !== turnId);
|
|
378
|
+
for (const [toolCallId, toolCall] of state.toolCalls.entries()) {
|
|
379
|
+
if (toolCall.turnId === turnId) {
|
|
380
|
+
state.toolCalls.delete(toolCallId);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (state.currentTurnId === turnId) {
|
|
385
|
+
state.currentTurnId = null;
|
|
386
|
+
}
|
|
387
|
+
if (state.lastTurnId === turnId) {
|
|
388
|
+
state.lastTurnId = null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function findLatestTranscriptEntry<TType extends BtwTranscriptEntry["type"]>(
|
|
393
|
+
state: BtwTranscriptState,
|
|
394
|
+
turnId: number,
|
|
395
|
+
type: TType,
|
|
396
|
+
): Extract<BtwTranscriptEntry, { type: TType }> | undefined {
|
|
397
|
+
for (let i = state.entries.length - 1; i >= 0; i--) {
|
|
398
|
+
const entry = state.entries[i];
|
|
399
|
+
if (entry.turnId === turnId && entry.type === type) {
|
|
400
|
+
return entry as Extract<BtwTranscriptEntry, { type: TType }>;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function ensureTranscriptTurnForUserMessage(state: BtwTranscriptState): number {
|
|
408
|
+
if (state.currentTurnId !== null) {
|
|
409
|
+
const currentAssistant = findLatestTranscriptEntry(state, state.currentTurnId, "assistant-text");
|
|
410
|
+
if (currentAssistant && !currentAssistant.streaming) {
|
|
411
|
+
finishTranscriptTurn(state, state.currentTurnId);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return ensureTranscriptTurn(state);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function extractMessageText(message: { content?: AssistantMessage["content"] }): string {
|
|
419
|
+
return Array.isArray(message.content) ? extractText(message.content, "text") : "";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function upsertUserMessageEntry(state: BtwTranscriptState, turnId: number, text: string): void {
|
|
423
|
+
if (!text) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const existing = findLatestTranscriptEntry(state, turnId, "user-message");
|
|
428
|
+
if (existing) {
|
|
429
|
+
existing.text = text;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
appendTranscriptEntry(state, { type: "user-message", turnId, text });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function upsertTranscriptTextEntry(
|
|
437
|
+
state: BtwTranscriptState,
|
|
438
|
+
turnId: number,
|
|
439
|
+
type: "thinking" | "assistant-text",
|
|
440
|
+
text: string,
|
|
441
|
+
streaming: boolean,
|
|
442
|
+
): void {
|
|
443
|
+
if (!text) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const existing = findLatestTranscriptEntry(state, turnId, type);
|
|
448
|
+
if (existing) {
|
|
449
|
+
existing.text = text;
|
|
450
|
+
existing.streaming = streaming;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
appendTranscriptEntry(state, { type, turnId, text, streaming });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function summarizeToolResult(value: unknown, maxLength = 400): { content: string; truncated: boolean } {
|
|
458
|
+
let content = "";
|
|
459
|
+
|
|
460
|
+
if (value && typeof value === "object") {
|
|
461
|
+
const toolValue = value as {
|
|
462
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
463
|
+
error?: unknown;
|
|
464
|
+
message?: unknown;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
if (Array.isArray(toolValue.content)) {
|
|
468
|
+
content = toolValue.content
|
|
469
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
470
|
+
.map((part) => part.text ?? "")
|
|
471
|
+
.join("\n")
|
|
472
|
+
.trim();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!content && typeof toolValue.error === "string") {
|
|
476
|
+
content = toolValue.error;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!content && typeof toolValue.message === "string") {
|
|
480
|
+
content = toolValue.message;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!content) {
|
|
485
|
+
if (typeof value === "string") {
|
|
486
|
+
content = value;
|
|
487
|
+
} else if (value !== undefined) {
|
|
488
|
+
try {
|
|
489
|
+
content = JSON.stringify(value, null, 2);
|
|
490
|
+
} catch {
|
|
491
|
+
content = String(value);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!content) {
|
|
497
|
+
content = "(no tool output)";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const truncated = content.length > maxLength;
|
|
501
|
+
return {
|
|
502
|
+
content: truncated ? `${content.slice(0, maxLength - 3)}...` : content,
|
|
503
|
+
truncated,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function ensureToolCallEntry(
|
|
508
|
+
state: BtwTranscriptState,
|
|
509
|
+
turnId: number,
|
|
510
|
+
toolCallId: string,
|
|
511
|
+
toolName: string,
|
|
512
|
+
args: string,
|
|
513
|
+
): { turnId: number; callEntryId: number; resultEntryId?: number } {
|
|
514
|
+
const existing = state.toolCalls.get(toolCallId);
|
|
515
|
+
if (existing) {
|
|
516
|
+
return existing;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const callEntry = appendTranscriptEntry(state, {
|
|
520
|
+
type: "tool-call",
|
|
521
|
+
turnId,
|
|
522
|
+
toolCallId,
|
|
523
|
+
toolName,
|
|
524
|
+
args,
|
|
525
|
+
});
|
|
526
|
+
const record = { turnId, callEntryId: callEntry.id };
|
|
527
|
+
state.toolCalls.set(toolCallId, record);
|
|
528
|
+
return record;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function upsertToolResultEntry(
|
|
532
|
+
state: BtwTranscriptState,
|
|
533
|
+
turnId: number,
|
|
534
|
+
toolCallId: string,
|
|
535
|
+
toolName: string,
|
|
536
|
+
content: string,
|
|
537
|
+
truncated: boolean,
|
|
538
|
+
isError: boolean,
|
|
539
|
+
streaming: boolean,
|
|
540
|
+
): void {
|
|
541
|
+
const toolCall = ensureToolCallEntry(state, turnId, toolCallId, toolName, "");
|
|
542
|
+
const existing =
|
|
543
|
+
toolCall.resultEntryId !== undefined
|
|
544
|
+
? state.entries.find((entry) => entry.id === toolCall.resultEntryId && entry.type === "tool-result")
|
|
545
|
+
: undefined;
|
|
546
|
+
|
|
547
|
+
if (existing && existing.type === "tool-result") {
|
|
548
|
+
existing.content = content;
|
|
549
|
+
existing.truncated = truncated;
|
|
550
|
+
existing.isError = isError;
|
|
551
|
+
existing.streaming = streaming;
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const resultEntry = appendTranscriptEntry(state, {
|
|
556
|
+
type: "tool-result",
|
|
557
|
+
turnId,
|
|
558
|
+
toolCallId,
|
|
559
|
+
toolName,
|
|
560
|
+
content,
|
|
561
|
+
truncated,
|
|
562
|
+
isError,
|
|
563
|
+
streaming,
|
|
564
|
+
});
|
|
565
|
+
toolCall.resultEntryId = resultEntry.id;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function applyAssistantMessageToTranscript(
|
|
569
|
+
state: BtwTranscriptState,
|
|
570
|
+
turnId: number,
|
|
571
|
+
message: AgentSessionEvent extends { message: infer T } ? T : never,
|
|
572
|
+
streaming: boolean,
|
|
573
|
+
): void {
|
|
574
|
+
if (!message || typeof message !== "object" || (message as { role?: string }).role !== "assistant") {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const assistantMessage = message as AssistantMessage;
|
|
579
|
+
const thinking = extractThinking(assistantMessage);
|
|
580
|
+
const answer = extractMessageText(assistantMessage);
|
|
581
|
+
|
|
582
|
+
if (thinking) {
|
|
583
|
+
upsertTranscriptTextEntry(state, turnId, "thinking", thinking, streaming);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (answer) {
|
|
587
|
+
upsertTranscriptTextEntry(state, turnId, "assistant-text", answer, streaming);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEvent): void {
|
|
592
|
+
switch (event.type) {
|
|
593
|
+
case "turn_start": {
|
|
594
|
+
ensureTranscriptTurn(state);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
case "message_start": {
|
|
598
|
+
if (event.message.role === "user") {
|
|
599
|
+
const turnId = ensureTranscriptTurnForUserMessage(state);
|
|
600
|
+
upsertUserMessageEntry(state, turnId, extractMessageText(event.message));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (event.message.role === "assistant") {
|
|
605
|
+
const turnId = ensureTranscriptTurn(state);
|
|
606
|
+
applyAssistantMessageToTranscript(state, turnId, event.message, true);
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
case "message_update": {
|
|
611
|
+
if (event.message.role !== "assistant") {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const turnId = ensureTranscriptTurn(state);
|
|
616
|
+
applyAssistantMessageToTranscript(state, turnId, event.message, true);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
case "message_end": {
|
|
620
|
+
if (event.message.role === "user") {
|
|
621
|
+
const turnId = ensureTranscriptTurnForUserMessage(state);
|
|
622
|
+
upsertUserMessageEntry(state, turnId, extractMessageText(event.message));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (event.message.role === "assistant") {
|
|
627
|
+
const turnId = ensureTranscriptTurn(state);
|
|
628
|
+
applyAssistantMessageToTranscript(state, turnId, event.message, false);
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
case "tool_execution_start": {
|
|
633
|
+
const turnId = ensureTranscriptTurn(state);
|
|
634
|
+
ensureToolCallEntry(state, turnId, event.toolCallId, event.toolName, formatToolPreview(event.args));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
case "tool_execution_update": {
|
|
638
|
+
const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state);
|
|
639
|
+
const result = summarizeToolResult(event.partialResult);
|
|
640
|
+
upsertToolResultEntry(
|
|
641
|
+
state,
|
|
642
|
+
turnId,
|
|
643
|
+
event.toolCallId,
|
|
644
|
+
event.toolName,
|
|
645
|
+
result.content,
|
|
646
|
+
result.truncated,
|
|
647
|
+
false,
|
|
648
|
+
true,
|
|
649
|
+
);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
case "tool_execution_end": {
|
|
653
|
+
const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state);
|
|
654
|
+
const result = summarizeToolResult(event.result);
|
|
655
|
+
upsertToolResultEntry(
|
|
656
|
+
state,
|
|
657
|
+
turnId,
|
|
658
|
+
event.toolCallId,
|
|
659
|
+
event.toolName,
|
|
660
|
+
result.content,
|
|
661
|
+
result.truncated,
|
|
662
|
+
event.isError,
|
|
663
|
+
false,
|
|
664
|
+
);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
case "turn_end": {
|
|
668
|
+
finishTranscriptTurn(state);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
default:
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDetails): void {
|
|
677
|
+
const turnId = ensureTranscriptTurn(state);
|
|
678
|
+
upsertUserMessageEntry(state, turnId, details.question);
|
|
679
|
+
if (details.thinking) {
|
|
680
|
+
upsertTranscriptTextEntry(state, turnId, "thinking", details.thinking, false);
|
|
681
|
+
}
|
|
682
|
+
upsertTranscriptTextEntry(state, turnId, "assistant-text", details.answer, false);
|
|
683
|
+
finishTranscriptTurn(state, turnId);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function setTranscriptFailure(state: BtwTranscriptState, message: string): void {
|
|
687
|
+
const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state);
|
|
688
|
+
upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false);
|
|
689
|
+
finishTranscriptTurn(state, turnId);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
|
|
693
|
+
return entries.some(
|
|
694
|
+
(entry) =>
|
|
695
|
+
(entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") &&
|
|
696
|
+
entry.streaming,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function getCompletedExchangeCount(entries: BtwTranscript): number {
|
|
701
|
+
return entries.filter((entry) => entry.type === "assistant-text" && !entry.streaming).length;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext["ui"]["theme"]): string[] {
|
|
705
|
+
if (entries.length === 0) {
|
|
706
|
+
return [theme.fg("dim", "No BTW thread yet. Ask a side question to start one.")];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const lines: string[] = [];
|
|
710
|
+
const userBadge = buildTranscriptBadge(theme, "You", "userMessageBg", "accent");
|
|
711
|
+
const thinkingBadge = buildTranscriptBadge(theme, "Thinking", "toolPendingBg", "warning");
|
|
712
|
+
const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning");
|
|
713
|
+
const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success");
|
|
714
|
+
const separator = theme.fg("borderMuted", "────────────────────────────────────────");
|
|
715
|
+
const blockIndent = " ";
|
|
716
|
+
const resultIndent = blockIndent;
|
|
717
|
+
|
|
718
|
+
const pushBlankLine = () => {
|
|
719
|
+
if (lines.length > 0 && lines[lines.length - 1] !== "") {
|
|
720
|
+
lines.push("");
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const pushInlineBlock = (
|
|
725
|
+
header: string,
|
|
726
|
+
text: string,
|
|
727
|
+
options: { blankBefore?: boolean; style?: (value: string) => string } = {},
|
|
728
|
+
) => {
|
|
729
|
+
const bodyLines = text.split("\n");
|
|
730
|
+
const style = options.style ?? ((value: string) => value);
|
|
731
|
+
if (options.blankBefore !== false) {
|
|
732
|
+
pushBlankLine();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const firstLine = bodyLines.shift() ?? "";
|
|
736
|
+
lines.push(`${header}${firstLine ? ` ${style(firstLine)}` : ""}`);
|
|
737
|
+
for (const line of bodyLines) {
|
|
738
|
+
lines.push(`${blockIndent}${style(line)}`);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const pushStackedBlock = (
|
|
743
|
+
header: string,
|
|
744
|
+
text: string,
|
|
745
|
+
options: { blankBefore?: boolean; indent?: string; style?: (value: string) => string } = {},
|
|
746
|
+
) => {
|
|
747
|
+
const bodyLines = text.split("\n");
|
|
748
|
+
const indent = options.indent ?? blockIndent;
|
|
749
|
+
const style = options.style ?? ((value: string) => value);
|
|
750
|
+
if (options.blankBefore !== false) {
|
|
751
|
+
pushBlankLine();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
lines.push(header);
|
|
755
|
+
for (const line of bodyLines) {
|
|
756
|
+
lines.push(`${indent}${style(line)}`);
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
for (const entry of entries) {
|
|
761
|
+
if (entry.type === "turn-boundary") {
|
|
762
|
+
if (entry.phase === "start" && lines.length > 0) {
|
|
763
|
+
pushBlankLine();
|
|
764
|
+
lines.push(separator);
|
|
765
|
+
}
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (entry.type === "user-message") {
|
|
770
|
+
pushInlineBlock(userBadge, entry.text, { blankBefore: false });
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (entry.type === "thinking") {
|
|
775
|
+
const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge;
|
|
776
|
+
pushStackedBlock(thinkingHeader, entry.text, {
|
|
777
|
+
style: (line) => theme.fg("warning", theme.italic(line)),
|
|
778
|
+
});
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (entry.type === "tool-call") {
|
|
783
|
+
const toolLabel = theme.fg("warning", theme.bold(entry.toolName));
|
|
784
|
+
const argsLabel = entry.args ? theme.fg("dim", ` · ${entry.args}`) : "";
|
|
785
|
+
pushInlineBlock(toolBadge, `${toolLabel}${argsLabel}`);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (entry.type === "tool-result") {
|
|
790
|
+
const resultHeaderLabel = entry.isError
|
|
791
|
+
? theme.fg("error", "↳ error")
|
|
792
|
+
: entry.streaming
|
|
793
|
+
? theme.fg("warning", "↳ streaming result")
|
|
794
|
+
: theme.fg("dim", "↳ result");
|
|
795
|
+
const truncationLabel = entry.truncated ? theme.fg("dim", " (truncated)") : "";
|
|
796
|
+
pushStackedBlock(`${resultHeaderLabel}${truncationLabel}`, entry.content, {
|
|
797
|
+
blankBefore: false,
|
|
798
|
+
indent: resultIndent,
|
|
799
|
+
style: (line) => (entry.isError ? theme.fg("error", line) : theme.fg("dim", line)),
|
|
800
|
+
});
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (entry.type === "assistant-text") {
|
|
805
|
+
const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge;
|
|
806
|
+
pushStackedBlock(assistantHeader, entry.text);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return lines;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function getLastAssistantMessage(session: AgentSession): AssistantMessage | null {
|
|
814
|
+
for (let i = session.state.messages.length - 1; i >= 0; i--) {
|
|
815
|
+
const message = session.state.messages[i];
|
|
816
|
+
if (message.role === "assistant") {
|
|
817
|
+
return message as AssistantMessage;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
type BtwHandoffExchange = {
|
|
825
|
+
user: string;
|
|
826
|
+
assistant: string;
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
function buildBtwMessageContent(question: string, answer: string): string {
|
|
830
|
+
return `Q: ${question}\n\nA: ${answer}`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function formatThread(thread: BtwHandoffExchange[]): string {
|
|
834
|
+
return thread.map((entry) => `User: ${entry.user.trim()}\nAssistant: ${entry.assistant.trim()}`).join("\n\n---\n\n");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function isThreadContinuationMarker(messages: Message[], index: number): boolean {
|
|
838
|
+
const userMessage = messages[index];
|
|
839
|
+
const assistantMessage = messages[index + 1];
|
|
840
|
+
return (
|
|
841
|
+
userMessage?.role === "user" &&
|
|
842
|
+
extractMessageText(userMessage) === BTW_CONTINUE_THREAD_USER_TEXT &&
|
|
843
|
+
assistantMessage?.role === "assistant" &&
|
|
844
|
+
extractMessageText(assistantMessage) === BTW_CONTINUE_THREAD_ASSISTANT_TEXT
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffExchange[] {
|
|
849
|
+
const handoffMessages = sessionRuntime.session.state.messages.slice(sessionRuntime.sideThreadStartIndex);
|
|
850
|
+
const threadMessages = isThreadContinuationMarker(handoffMessages, 0) ? handoffMessages.slice(2) : handoffMessages;
|
|
851
|
+
const exchanges: BtwHandoffExchange[] = [];
|
|
852
|
+
let currentUser = "";
|
|
853
|
+
let currentAssistant = "";
|
|
854
|
+
|
|
855
|
+
const pushCurrent = () => {
|
|
856
|
+
if (!currentUser && !currentAssistant) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
exchanges.push({
|
|
861
|
+
user: currentUser.trim() || "(No user prompt)",
|
|
862
|
+
assistant: currentAssistant.trim() || "(No assistant response)",
|
|
863
|
+
});
|
|
864
|
+
currentUser = "";
|
|
865
|
+
currentAssistant = "";
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
for (const message of threadMessages) {
|
|
869
|
+
if (message.role !== "user" && message.role !== "assistant") {
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const text = extractMessageText(message).trim();
|
|
874
|
+
if (!text) {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (message.role === "user") {
|
|
879
|
+
pushCurrent();
|
|
880
|
+
currentUser = text;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
pushCurrent();
|
|
888
|
+
return exchanges;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function saveVisibleBtwNote(
|
|
892
|
+
pi: ExtensionAPI,
|
|
893
|
+
details: BtwDetails,
|
|
894
|
+
saveRequested: boolean,
|
|
895
|
+
wasBusy: boolean,
|
|
896
|
+
): SaveState {
|
|
897
|
+
if (!saveRequested) {
|
|
898
|
+
return "not-saved";
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const message = {
|
|
902
|
+
customType: BTW_MESSAGE_TYPE,
|
|
903
|
+
content: buildBtwMessageContent(details.question, details.answer),
|
|
904
|
+
display: true,
|
|
905
|
+
details,
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
if (wasBusy) {
|
|
909
|
+
pi.sendMessage(message, { deliverAs: "followUp" });
|
|
910
|
+
return "queued";
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
pi.sendMessage(message);
|
|
914
|
+
return "saved";
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
|
|
918
|
+
if (ctx.hasUI) {
|
|
919
|
+
ctx.ui.notify(message, level);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function getOverlayTitle(mode: BtwThreadMode): string {
|
|
924
|
+
return mode === "tangent" ? "BTW tangent" : "BTW";
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function buildTranscriptBadge(
|
|
928
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
929
|
+
label: string,
|
|
930
|
+
background: string,
|
|
931
|
+
foreground: string,
|
|
932
|
+
): string {
|
|
933
|
+
return theme.bg(background, theme.fg(foreground, theme.bold(` ${label} `)));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
class BtwOverlayComponent extends Container implements Focusable {
|
|
937
|
+
private readonly input: Input;
|
|
938
|
+
private readonly transcript: Container;
|
|
939
|
+
private readonly statusText: Text;
|
|
940
|
+
private readonly modeText: Text;
|
|
941
|
+
private readonly summaryText: Text;
|
|
942
|
+
private readonly hintsText: Text;
|
|
943
|
+
private readonly readTranscriptEntries: () => BtwTranscript;
|
|
944
|
+
private readonly getStatus: () => string | null;
|
|
945
|
+
private readonly getMode: () => BtwThreadMode;
|
|
946
|
+
private readonly onSubmitCallback: (value: string) => void;
|
|
947
|
+
private readonly onDismissCallback: () => void;
|
|
948
|
+
private readonly onUnfocusCallback: () => void;
|
|
949
|
+
private readonly tui: TUI;
|
|
950
|
+
private readonly theme: ExtensionContext["ui"]["theme"];
|
|
951
|
+
private transcriptLines: string[] = [];
|
|
952
|
+
private transcriptScrollOffset = 0;
|
|
953
|
+
private transcriptViewportHeight = 8;
|
|
954
|
+
private followTranscript = true;
|
|
955
|
+
private _focused = false;
|
|
956
|
+
|
|
957
|
+
get focused(): boolean {
|
|
958
|
+
return this._focused;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
set focused(value: boolean) {
|
|
962
|
+
this._focused = value;
|
|
963
|
+
this.input.focused = value;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
constructor(
|
|
967
|
+
tui: TUI,
|
|
968
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
969
|
+
keybindings: KeybindingsManager,
|
|
970
|
+
readTranscriptEntries: () => BtwTranscript,
|
|
971
|
+
getStatus: () => string | null,
|
|
972
|
+
getMode: () => BtwThreadMode,
|
|
973
|
+
onSubmit: (value: string) => void,
|
|
974
|
+
onDismiss: () => void,
|
|
975
|
+
onUnfocus: () => void,
|
|
976
|
+
) {
|
|
977
|
+
super();
|
|
978
|
+
this.tui = tui;
|
|
979
|
+
this.theme = theme;
|
|
980
|
+
this.readTranscriptEntries = readTranscriptEntries;
|
|
981
|
+
this.getStatus = getStatus;
|
|
982
|
+
this.getMode = getMode;
|
|
983
|
+
this.onSubmitCallback = onSubmit;
|
|
984
|
+
this.onDismissCallback = onDismiss;
|
|
985
|
+
this.onUnfocusCallback = onUnfocus;
|
|
986
|
+
|
|
987
|
+
this.modeText = new Text("", 1, 0);
|
|
988
|
+
this.summaryText = new Text("", 1, 0);
|
|
989
|
+
this.transcript = new Container();
|
|
990
|
+
this.statusText = new Text("", 1, 0);
|
|
991
|
+
|
|
992
|
+
this.input = new Input();
|
|
993
|
+
this.input.onSubmit = (value) => {
|
|
994
|
+
this.followTranscript = true;
|
|
995
|
+
this.onSubmitCallback(value);
|
|
996
|
+
};
|
|
997
|
+
this.input.onEscape = () => {
|
|
998
|
+
this.onDismissCallback();
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
this.hintsText = new Text("", 1, 0);
|
|
1002
|
+
|
|
1003
|
+
const originalHandleInput = this.input.handleInput.bind(this.input);
|
|
1004
|
+
this.input.handleInput = (data: string) => {
|
|
1005
|
+
if (keybindings.matches(data, "selectCancel")) {
|
|
1006
|
+
this.onDismissCallback();
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
originalHandleInput(data);
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
this.refresh();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
1016
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
1017
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
1018
|
+
return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private ruleLine(innerWidth: number): string {
|
|
1022
|
+
return this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
1026
|
+
const left = edge === "top" ? "┌" : "└";
|
|
1027
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
1028
|
+
return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private wrapTranscript(innerWidth: number): string[] {
|
|
1032
|
+
const wrapped: string[] = [];
|
|
1033
|
+
for (const line of this.transcriptLines) {
|
|
1034
|
+
if (!line) {
|
|
1035
|
+
wrapped.push("");
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
wrapped.push(...wrapTextWithAnsi(line, Math.max(1, innerWidth)));
|
|
1039
|
+
}
|
|
1040
|
+
return wrapped;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
private getDialogHeight(): number {
|
|
1044
|
+
const terminalRows = process.stdout.rows ?? 30;
|
|
1045
|
+
return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
handleInput(data: string): void {
|
|
1049
|
+
if (matchesBtwFocusShortcut(data)) {
|
|
1050
|
+
this.onUnfocusCallback();
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
1055
|
+
this.followTranscript = false;
|
|
1056
|
+
this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset - Math.max(1, this.transcriptViewportHeight - 1));
|
|
1057
|
+
this.tui.requestRender();
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
1062
|
+
this.transcriptScrollOffset += Math.max(1, this.transcriptViewportHeight - 1);
|
|
1063
|
+
this.tui.requestRender();
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
this.input.handleInput(data);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private inputFrameLine(dialogWidth: number): string {
|
|
1071
|
+
const targetWidth = Math.max(1, dialogWidth - 2);
|
|
1072
|
+
const previousFocused = this.input.focused;
|
|
1073
|
+
// Input.render() emits CURSOR_MARKER when focused. In overlay mode that APC marker
|
|
1074
|
+
// can skew width/composition on this one row before the TUI strips it, producing a
|
|
1075
|
+
// right-edge notch and shifted border. Render the embedded input unfocused here so
|
|
1076
|
+
// the row stays geometrically stable while the overlay still owns keyboard input.
|
|
1077
|
+
this.input.focused = false;
|
|
1078
|
+
try {
|
|
1079
|
+
const inputLine = this.input.render(targetWidth)[0] ?? "";
|
|
1080
|
+
return `${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`;
|
|
1081
|
+
} finally {
|
|
1082
|
+
this.input.focused = previousFocused;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
override render(width: number): string[] {
|
|
1087
|
+
const dialogWidth = Math.max(24, width);
|
|
1088
|
+
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
1089
|
+
const transcriptLines = this.wrapTranscript(innerWidth);
|
|
1090
|
+
const dialogHeight = this.getDialogHeight();
|
|
1091
|
+
const chromeHeight = 8;
|
|
1092
|
+
const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
|
|
1093
|
+
this.transcriptViewportHeight = transcriptHeight;
|
|
1094
|
+
|
|
1095
|
+
const maxScroll = Math.max(0, transcriptLines.length - transcriptHeight);
|
|
1096
|
+
if (this.followTranscript) {
|
|
1097
|
+
this.transcriptScrollOffset = maxScroll;
|
|
1098
|
+
} else {
|
|
1099
|
+
this.transcriptScrollOffset = Math.max(0, Math.min(this.transcriptScrollOffset, maxScroll));
|
|
1100
|
+
if (this.transcriptScrollOffset >= maxScroll) {
|
|
1101
|
+
this.followTranscript = true;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const visibleTranscript = transcriptLines.slice(
|
|
1106
|
+
this.transcriptScrollOffset,
|
|
1107
|
+
this.transcriptScrollOffset + transcriptHeight,
|
|
1108
|
+
);
|
|
1109
|
+
const transcriptPadCount = Math.max(0, transcriptHeight - visibleTranscript.length);
|
|
1110
|
+
const hiddenAbove = this.transcriptScrollOffset;
|
|
1111
|
+
const hiddenBelow = Math.max(0, maxScroll - this.transcriptScrollOffset);
|
|
1112
|
+
const summary =
|
|
1113
|
+
hiddenAbove || hiddenBelow
|
|
1114
|
+
? `${this.summaryText.text.trim()} · ↑${hiddenAbove} ↓${hiddenBelow}`
|
|
1115
|
+
: this.summaryText.text.trim();
|
|
1116
|
+
|
|
1117
|
+
const lines = [this.borderLine(innerWidth, "top")];
|
|
1118
|
+
|
|
1119
|
+
lines.push(this.frameLine(this.theme.fg("accent", this.theme.bold(this.modeText.text.trim())), innerWidth));
|
|
1120
|
+
lines.push(this.frameLine(this.theme.fg("dim", summary), innerWidth));
|
|
1121
|
+
lines.push(this.ruleLine(innerWidth));
|
|
1122
|
+
|
|
1123
|
+
for (const line of visibleTranscript) {
|
|
1124
|
+
lines.push(this.frameLine(line, innerWidth));
|
|
1125
|
+
}
|
|
1126
|
+
for (let i = 0; i < transcriptPadCount; i++) {
|
|
1127
|
+
lines.push(this.frameLine("", innerWidth));
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
lines.push(this.ruleLine(innerWidth));
|
|
1131
|
+
lines.push(this.frameLine(this.theme.fg("warning", this.statusText.text.trim()), innerWidth));
|
|
1132
|
+
lines.push(this.inputFrameLine(dialogWidth));
|
|
1133
|
+
lines.push(this.frameLine(this.theme.fg("dim", this.hintsText.text.trim()), innerWidth));
|
|
1134
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
1135
|
+
|
|
1136
|
+
return lines;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
setDraft(value: string): void {
|
|
1140
|
+
this.input.setValue(value);
|
|
1141
|
+
this.tui.requestRender();
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
getDraft(): string {
|
|
1145
|
+
return this.input.getValue();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
getTranscriptEntries(): BtwTranscript {
|
|
1149
|
+
return this.readTranscriptEntries().map((entry) => ({ ...entry }));
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
refresh(): void {
|
|
1153
|
+
this.modeText.setText(`${getOverlayTitle(this.getMode())} · hidden thread preserved`);
|
|
1154
|
+
const entries = this.readTranscriptEntries();
|
|
1155
|
+
const exchanges = getCompletedExchangeCount(entries);
|
|
1156
|
+
const active = hasStreamingTranscriptEntry(entries) ? " · streaming" : " · idle";
|
|
1157
|
+
this.summaryText.setText(`${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`);
|
|
1158
|
+
|
|
1159
|
+
this.transcriptLines = buildOverlayTranscript(entries, this.theme);
|
|
1160
|
+
this.transcript.clear();
|
|
1161
|
+
for (const line of this.transcriptLines) {
|
|
1162
|
+
this.transcript.addChild(new Text(line, 1, 0));
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
|
|
1166
|
+
this.statusText.setText(status);
|
|
1167
|
+
this.hintsText.setText("Enter submit · Alt+/ toggle focus · Escape dismiss · PgUp/PgDn scroll");
|
|
1168
|
+
this.tui.requestRender();
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
export default function (pi: ExtensionAPI) {
|
|
1173
|
+
let pendingThread: BtwDetails[] = [];
|
|
1174
|
+
let pendingMode: BtwThreadMode = "contextual";
|
|
1175
|
+
let transcriptState = createEmptyTranscriptState();
|
|
1176
|
+
let overlayStatus: string | null = null;
|
|
1177
|
+
let overlayDraft = "";
|
|
1178
|
+
let overlayRuntime: OverlayRuntime | null = null;
|
|
1179
|
+
let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
|
|
1180
|
+
let activeBtwSession: BtwSessionRuntime | null = null;
|
|
1181
|
+
|
|
1182
|
+
function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
|
|
1183
|
+
const activeCtx = ctx ?? lastUiContext;
|
|
1184
|
+
if (activeCtx?.hasUI) {
|
|
1185
|
+
activeCtx.ui.setWidget("btw", undefined);
|
|
1186
|
+
overlayRuntime?.refresh?.();
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function setOverlayStatus(status: string | null, ctx?: ExtensionContext | ExtensionCommandContext): void {
|
|
1191
|
+
overlayStatus = status;
|
|
1192
|
+
syncUi(ctx);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function setOverlayDraft(value: string): void {
|
|
1196
|
+
overlayDraft = value;
|
|
1197
|
+
overlayRuntime?.setDraft?.(value);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function dismissOverlay(): void {
|
|
1201
|
+
overlayRuntime?.close?.();
|
|
1202
|
+
overlayRuntime = null;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function toggleOverlayFocus(): void {
|
|
1206
|
+
const handle = overlayRuntime?.handle;
|
|
1207
|
+
if (!handle) {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
handle.setHidden(false);
|
|
1212
|
+
if (handle.isFocused()) {
|
|
1213
|
+
handle.unfocus();
|
|
1214
|
+
} else {
|
|
1215
|
+
handle.focus();
|
|
1216
|
+
}
|
|
1217
|
+
overlayRuntime?.refresh?.();
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function focusOverlay(): void {
|
|
1221
|
+
const handle = overlayRuntime?.handle;
|
|
1222
|
+
if (!handle) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
handle.setHidden(false);
|
|
1227
|
+
handle.focus();
|
|
1228
|
+
overlayRuntime?.refresh?.();
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
|
|
1232
|
+
if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
try {
|
|
1237
|
+
unsubscribe();
|
|
1238
|
+
} catch {
|
|
1239
|
+
// Ignore unsubscribe errors during BTW session replacement/shutdown.
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function clearBtwSessionSubscriptions(sessionRuntime: BtwSessionRuntime): void {
|
|
1244
|
+
for (const unsubscribe of [...sessionRuntime.subscriptions]) {
|
|
1245
|
+
removeBtwSessionSubscription(sessionRuntime, unsubscribe);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function handleBtwSessionEvent(
|
|
1250
|
+
sessionRuntime: BtwSessionRuntime,
|
|
1251
|
+
event: AgentSessionEvent,
|
|
1252
|
+
ctx?: ExtensionContext | ExtensionCommandContext,
|
|
1253
|
+
): void {
|
|
1254
|
+
if (activeBtwSession?.session !== sessionRuntime.session || !overlayRuntime) {
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
176
1257
|
|
|
177
|
-
|
|
178
|
-
systemPrompt: [ctx.getSystemPrompt(), BTW_SYSTEM_PROMPT].filter(Boolean).join("\n\n"),
|
|
179
|
-
messages,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
1258
|
+
applyTranscriptEvent(transcriptState, event);
|
|
182
1259
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
1260
|
+
if (event.type === "tool_execution_start") {
|
|
1261
|
+
setOverlayStatus(`⏳ running tool: ${event.toolName}`, ctx);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
186
1264
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
1265
|
+
if (event.type === "tool_execution_end") {
|
|
1266
|
+
setOverlayStatus(sessionRuntime.session.isStreaming ? `⏳ running tool: ${event.toolName}` : "⏳ streaming...", ctx);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
190
1269
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
1270
|
+
if (event.type === "turn_end") {
|
|
1271
|
+
setOverlayStatus("⏳ streaming...", ctx);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
if (
|
|
1276
|
+
event.type === "message_start" ||
|
|
1277
|
+
event.type === "message_update" ||
|
|
1278
|
+
event.type === "message_end" ||
|
|
1279
|
+
event.type === "turn_start"
|
|
1280
|
+
) {
|
|
1281
|
+
syncUi(ctx);
|
|
1282
|
+
}
|
|
199
1283
|
}
|
|
200
1284
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
};
|
|
1285
|
+
function subscribeOverlayToActiveBtwSession(ctx?: ExtensionContext | ExtensionCommandContext): void {
|
|
1286
|
+
const sessionRuntime = activeBtwSession;
|
|
1287
|
+
if (!sessionRuntime || sessionRuntime.subscriptions.size > 0) {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
207
1290
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
1291
|
+
const unsubscribe = sessionRuntime.session.subscribe((event: AgentSessionEvent) => {
|
|
1292
|
+
handleBtwSessionEvent(sessionRuntime, event, ctx);
|
|
1293
|
+
});
|
|
1294
|
+
sessionRuntime.subscriptions.add(unsubscribe);
|
|
211
1295
|
}
|
|
212
1296
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
1297
|
+
async function disposeBtwSession(): Promise<void> {
|
|
1298
|
+
const current = activeBtwSession;
|
|
1299
|
+
activeBtwSession = null;
|
|
1300
|
+
if (!current) {
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
216
1303
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
1304
|
+
clearBtwSessionSubscriptions(current);
|
|
1305
|
+
|
|
1306
|
+
try {
|
|
1307
|
+
await current.session.abort();
|
|
1308
|
+
} catch {
|
|
1309
|
+
// Ignore abort errors during BTW session replacement/shutdown.
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
current.session.dispose();
|
|
220
1313
|
}
|
|
221
|
-
}
|
|
222
1314
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
let widgetStatus: string | null = null;
|
|
1315
|
+
async function dismissOverlaySession(): Promise<void> {
|
|
1316
|
+
dismissOverlay();
|
|
1317
|
+
await disposeBtwSession();
|
|
1318
|
+
}
|
|
228
1319
|
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
1320
|
+
async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime> {
|
|
1321
|
+
const { session } = await createAgentSession({
|
|
1322
|
+
sessionManager: SessionManager.inMemory(),
|
|
1323
|
+
model: ctx.model,
|
|
1324
|
+
modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
|
|
1325
|
+
thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel,
|
|
1326
|
+
tools: codingTools,
|
|
1327
|
+
resourceLoader: createBtwResourceLoader(ctx),
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode);
|
|
1331
|
+
if (seedMessages.length > 0) {
|
|
1332
|
+
session.agent.replaceMessages(seedMessages as typeof session.state.messages);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
|
|
1339
|
+
if (!ctx.model) {
|
|
1340
|
+
return null;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (activeBtwSession?.mode === mode) {
|
|
1344
|
+
return activeBtwSession;
|
|
234
1345
|
}
|
|
1346
|
+
|
|
1347
|
+
await disposeBtwSession();
|
|
1348
|
+
activeBtwSession = await createBtwSubSession(ctx, mode);
|
|
1349
|
+
return activeBtwSession;
|
|
235
1350
|
}
|
|
236
1351
|
|
|
237
|
-
function
|
|
1352
|
+
async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
|
|
238
1353
|
if (!ctx.hasUI) {
|
|
239
1354
|
return;
|
|
240
1355
|
}
|
|
1356
|
+
lastUiContext = ctx;
|
|
241
1357
|
|
|
242
|
-
if (
|
|
243
|
-
ctx
|
|
1358
|
+
if (overlayRuntime?.handle) {
|
|
1359
|
+
subscribeOverlayToActiveBtwSession(ctx);
|
|
1360
|
+
focusOverlay();
|
|
244
1361
|
return;
|
|
245
1362
|
}
|
|
246
1363
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const slot = slots[i];
|
|
263
|
-
if (i > 0) {
|
|
264
|
-
parts.push(dim("│ ───"));
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
parts.push(dim("│ ") + success("› ") + slot.question);
|
|
1364
|
+
const runtime: OverlayRuntime = {};
|
|
1365
|
+
const closeRuntime = () => {
|
|
1366
|
+
if (runtime.closed) {
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
runtime.closed = true;
|
|
1370
|
+
if (activeBtwSession) {
|
|
1371
|
+
clearBtwSessionSubscriptions(activeBtwSession);
|
|
1372
|
+
}
|
|
1373
|
+
runtime.handle?.hide();
|
|
1374
|
+
if (overlayRuntime === runtime) {
|
|
1375
|
+
overlayRuntime = null;
|
|
1376
|
+
}
|
|
1377
|
+
runtime.finish?.();
|
|
1378
|
+
};
|
|
268
1379
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
1380
|
+
runtime.close = closeRuntime;
|
|
1381
|
+
overlayRuntime = runtime;
|
|
1382
|
+
|
|
1383
|
+
void ctx.ui
|
|
1384
|
+
.custom<void>(
|
|
1385
|
+
async (tui, theme, keybindings, done) => {
|
|
1386
|
+
runtime.finish = () => {
|
|
1387
|
+
done();
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
const overlay = new BtwOverlayComponent(
|
|
1391
|
+
tui,
|
|
1392
|
+
theme,
|
|
1393
|
+
keybindings,
|
|
1394
|
+
() => transcriptState.entries,
|
|
1395
|
+
() => overlayStatus,
|
|
1396
|
+
() => pendingMode,
|
|
1397
|
+
(value) => {
|
|
1398
|
+
void submitFromOverlay(ctx, value);
|
|
1399
|
+
},
|
|
1400
|
+
() => {
|
|
1401
|
+
void dismissOverlaySession();
|
|
1402
|
+
},
|
|
1403
|
+
() => {
|
|
1404
|
+
overlayRuntime?.handle?.unfocus();
|
|
1405
|
+
overlayRuntime?.refresh?.();
|
|
1406
|
+
},
|
|
1407
|
+
);
|
|
1408
|
+
|
|
1409
|
+
overlay.focused = runtime.handle?.isFocused() ?? true;
|
|
1410
|
+
overlay.setDraft(overlayDraft);
|
|
1411
|
+
runtime.setDraft = (value) => {
|
|
1412
|
+
overlay.setDraft(value);
|
|
1413
|
+
};
|
|
1414
|
+
runtime.refresh = () => {
|
|
1415
|
+
overlay.focused = runtime.handle?.isFocused() ?? false;
|
|
1416
|
+
overlay.refresh();
|
|
1417
|
+
};
|
|
1418
|
+
runtime.close = () => {
|
|
1419
|
+
overlayDraft = overlay.getDraft();
|
|
1420
|
+
closeRuntime();
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
subscribeOverlayToActiveBtwSession(ctx);
|
|
1424
|
+
|
|
1425
|
+
if (runtime.closed) {
|
|
1426
|
+
done();
|
|
272
1427
|
}
|
|
273
1428
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
1429
|
+
return overlay;
|
|
1430
|
+
},
|
|
1431
|
+
{
|
|
1432
|
+
overlay: true,
|
|
1433
|
+
overlayOptions: {
|
|
1434
|
+
width: "78%",
|
|
1435
|
+
minWidth: 72,
|
|
1436
|
+
maxHeight: "78%",
|
|
1437
|
+
anchor: "top-center",
|
|
1438
|
+
margin: { top: 1, left: 2, right: 2 },
|
|
1439
|
+
nonCapturing: true,
|
|
1440
|
+
},
|
|
1441
|
+
onHandle: (handle) => {
|
|
1442
|
+
runtime.handle = handle;
|
|
1443
|
+
handle.focus();
|
|
1444
|
+
if (runtime.closed) {
|
|
1445
|
+
closeRuntime();
|
|
282
1446
|
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
1447
|
+
},
|
|
1448
|
+
},
|
|
1449
|
+
)
|
|
1450
|
+
.catch((error) => {
|
|
1451
|
+
if (overlayRuntime === runtime) {
|
|
1452
|
+
overlayRuntime = null;
|
|
288
1453
|
}
|
|
1454
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
289
1457
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
1458
|
+
async function dispatchBtwCommand(name: string, args: string, ctx: ExtensionCommandContext): Promise<boolean> {
|
|
1459
|
+
const trimmedArgs = args.trim();
|
|
293
1460
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
1461
|
+
if (name === "btw") {
|
|
1462
|
+
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
1463
|
+
if (!question) {
|
|
1464
|
+
await ensureBtwSession(ctx, pendingMode);
|
|
1465
|
+
await ensureOverlay(ctx);
|
|
1466
|
+
return true;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (pendingMode !== "contextual") {
|
|
1470
|
+
await resetThread(ctx, true, "contextual");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
await runBtw(ctx, question, save, "contextual");
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (name === "btw:tangent") {
|
|
1478
|
+
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
1479
|
+
if (pendingMode !== "tangent") {
|
|
1480
|
+
await resetThread(ctx, true, "tangent");
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
if (!question) {
|
|
1484
|
+
await ensureBtwSession(ctx, "tangent");
|
|
1485
|
+
await ensureOverlay(ctx);
|
|
1486
|
+
return true;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
await runBtw(ctx, question, save, "tangent");
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (name === "btw:new") {
|
|
1494
|
+
await resetThread(ctx, true, "contextual");
|
|
1495
|
+
const { question, save } = parseBtwArgs(trimmedArgs);
|
|
1496
|
+
if (question) {
|
|
1497
|
+
await runBtw(ctx, question, save, "contextual");
|
|
1498
|
+
} else {
|
|
1499
|
+
await ensureBtwSession(ctx, "contextual");
|
|
1500
|
+
setOverlayStatus("Started a fresh BTW thread.", ctx);
|
|
1501
|
+
await ensureOverlay(ctx);
|
|
1502
|
+
notify(ctx, "Started a fresh BTW thread.", "info");
|
|
1503
|
+
}
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (name === "btw:clear") {
|
|
1508
|
+
await resetThread(ctx);
|
|
1509
|
+
dismissOverlay();
|
|
1510
|
+
notify(ctx, "Cleared BTW thread.", "info");
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
if (name === "btw:inject") {
|
|
1515
|
+
if (pendingThread.length === 0) {
|
|
1516
|
+
notify(ctx, "No BTW thread to inject.", "warning");
|
|
1517
|
+
return true;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
setOverlayStatus("⏳ injecting into the main session...", ctx);
|
|
1521
|
+
await ensureOverlay(ctx);
|
|
1522
|
+
|
|
1523
|
+
try {
|
|
1524
|
+
const { thread } = await getBtwHandoffThread(ctx);
|
|
1525
|
+
const instructions = trimmedArgs;
|
|
1526
|
+
const content = instructions
|
|
1527
|
+
? `Here is a side conversation I had. ${instructions}\n\n${formatThread(thread)}`
|
|
1528
|
+
: `Here is a side conversation I had for additional context:\n\n${formatThread(thread)}`;
|
|
1529
|
+
|
|
1530
|
+
sendThreadToMain(ctx, content);
|
|
1531
|
+
const count = thread.length;
|
|
1532
|
+
await resetThread(ctx);
|
|
1533
|
+
dismissOverlay();
|
|
1534
|
+
notify(ctx, `Injected BTW thread (${count} exchange${count === 1 ? "" : "s"}).`, "info");
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
setOverlayStatus("Inject failed. Thread preserved for retry or summarize.", ctx);
|
|
1537
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1538
|
+
}
|
|
1539
|
+
return true;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
if (name === "btw:summarize") {
|
|
1543
|
+
if (pendingThread.length === 0) {
|
|
1544
|
+
notify(ctx, "No BTW thread to summarize.", "warning");
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
setOverlayStatus("⏳ summarizing...", ctx);
|
|
1549
|
+
await ensureOverlay(ctx);
|
|
1550
|
+
|
|
1551
|
+
try {
|
|
1552
|
+
const { thread } = await getBtwHandoffThread(ctx);
|
|
1553
|
+
const summary = await summarizeThread(ctx, thread);
|
|
1554
|
+
const instructions = trimmedArgs;
|
|
1555
|
+
const content = instructions
|
|
1556
|
+
? `Here is a summary of a side conversation I had. ${instructions}\n\n${summary}`
|
|
1557
|
+
: `Here is a summary of a side conversation I had:\n\n${summary}`;
|
|
1558
|
+
|
|
1559
|
+
sendThreadToMain(ctx, content);
|
|
1560
|
+
const count = thread.length;
|
|
1561
|
+
await resetThread(ctx);
|
|
1562
|
+
dismissOverlay();
|
|
1563
|
+
notify(ctx, `Injected BTW summary (${count} exchange${count === 1 ? "" : "s"}).`, "info");
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
setOverlayStatus("Summarize failed. Thread preserved for retry or injection.", ctx);
|
|
1566
|
+
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1567
|
+
}
|
|
1568
|
+
return true;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
return false;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
|
|
1575
|
+
const trimmed = value.trim();
|
|
1576
|
+
const match = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize))(?:\s+(.*))?$/);
|
|
1577
|
+
if (!match) {
|
|
1578
|
+
return null;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
return {
|
|
1582
|
+
name: match[1],
|
|
1583
|
+
args: match[2]?.trim() ?? "",
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
async function submitFromOverlay(ctx: ExtensionCommandContext | ExtensionContext, value: string): Promise<void> {
|
|
1588
|
+
const question = value.trim();
|
|
1589
|
+
if (!question) {
|
|
1590
|
+
setOverlayStatus("Enter a BTW prompt before submitting.", ctx);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if (!("getSystemPrompt" in ctx)) {
|
|
1595
|
+
setOverlayStatus("BTW overlay submit requires a command context. Reopen BTW from a command.", ctx);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const btwCommand = parseOverlayBtwCommand(question);
|
|
1600
|
+
if (btwCommand) {
|
|
1601
|
+
setOverlayDraft("");
|
|
1602
|
+
await dispatchBtwCommand(btwCommand.name, btwCommand.args, ctx);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
setOverlayDraft("");
|
|
1607
|
+
setOverlayStatus("⏳ streaming...", ctx);
|
|
1608
|
+
syncUi(ctx);
|
|
1609
|
+
await runBtw(ctx, question, false, pendingMode);
|
|
299
1610
|
}
|
|
300
1611
|
|
|
301
|
-
function resetThread(
|
|
1612
|
+
async function resetThread(
|
|
302
1613
|
ctx: ExtensionContext | ExtensionCommandContext,
|
|
303
1614
|
persist = true,
|
|
304
1615
|
mode: BtwThreadMode = "contextual",
|
|
305
|
-
): void {
|
|
306
|
-
|
|
1616
|
+
): Promise<void> {
|
|
1617
|
+
await disposeBtwSession();
|
|
307
1618
|
pendingThread = [];
|
|
308
1619
|
pendingMode = mode;
|
|
309
|
-
|
|
310
|
-
|
|
1620
|
+
transcriptState = createEmptyTranscriptState();
|
|
1621
|
+
setOverlayDraft("");
|
|
1622
|
+
setOverlayStatus(null, ctx);
|
|
311
1623
|
if (persist) {
|
|
312
1624
|
const details: BtwResetDetails = { timestamp: Date.now(), mode };
|
|
313
1625
|
pi.appendEntry(BTW_RESET_TYPE, details);
|
|
314
1626
|
}
|
|
315
|
-
|
|
1627
|
+
syncUi(ctx);
|
|
316
1628
|
}
|
|
317
1629
|
|
|
318
|
-
function restoreThread(ctx: ExtensionContext): void {
|
|
319
|
-
|
|
1630
|
+
async function restoreThread(ctx: ExtensionContext): Promise<void> {
|
|
1631
|
+
await disposeBtwSession();
|
|
320
1632
|
pendingThread = [];
|
|
321
1633
|
pendingMode = "contextual";
|
|
322
|
-
|
|
323
|
-
|
|
1634
|
+
transcriptState = createEmptyTranscriptState();
|
|
1635
|
+
overlayDraft = "";
|
|
1636
|
+
lastUiContext = ctx;
|
|
1637
|
+
overlayStatus = null;
|
|
324
1638
|
|
|
325
1639
|
const branch = ctx.sessionManager.getBranch();
|
|
326
1640
|
let lastResetIndex = -1;
|
|
@@ -344,17 +1658,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
344
1658
|
}
|
|
345
1659
|
|
|
346
1660
|
pendingThread.push(details);
|
|
347
|
-
|
|
348
|
-
question: details.question,
|
|
349
|
-
modelLabel: `${details.provider}/${details.model}`,
|
|
350
|
-
thinking: details.thinking || "",
|
|
351
|
-
answer: details.answer,
|
|
352
|
-
done: true,
|
|
353
|
-
controller: new AbortController(),
|
|
354
|
-
});
|
|
1661
|
+
appendPersistedTranscriptTurn(transcriptState, details);
|
|
355
1662
|
}
|
|
356
1663
|
|
|
357
|
-
|
|
1664
|
+
syncUi(ctx);
|
|
358
1665
|
}
|
|
359
1666
|
|
|
360
1667
|
async function runBtw(
|
|
@@ -363,78 +1670,59 @@ export default function (pi: ExtensionAPI) {
|
|
|
363
1670
|
saveRequested: boolean,
|
|
364
1671
|
mode: BtwThreadMode,
|
|
365
1672
|
): Promise<void> {
|
|
1673
|
+
lastUiContext = ctx;
|
|
366
1674
|
const model = ctx.model;
|
|
367
1675
|
if (!model) {
|
|
1676
|
+
setOverlayStatus("No active model selected.", ctx);
|
|
368
1677
|
notify(ctx, "No active model selected.", "error");
|
|
369
1678
|
return;
|
|
370
1679
|
}
|
|
371
1680
|
|
|
372
1681
|
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
373
1682
|
if (!apiKey) {
|
|
374
|
-
|
|
1683
|
+
const message = `No credentials available for ${model.provider}/${model.id}.`;
|
|
1684
|
+
setOverlayStatus(message, ctx);
|
|
1685
|
+
notify(ctx, message, "error");
|
|
1686
|
+
await ensureOverlay(ctx);
|
|
375
1687
|
return;
|
|
376
1688
|
}
|
|
377
1689
|
|
|
1690
|
+
const sessionRuntime = await ensureBtwSession(ctx, mode);
|
|
1691
|
+
if (!sessionRuntime) {
|
|
1692
|
+
setOverlayStatus("No active model selected.", ctx);
|
|
1693
|
+
notify(ctx, "No active model selected.", "error");
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
const session = sessionRuntime.session;
|
|
378
1698
|
const wasBusy = !ctx.isIdle();
|
|
379
1699
|
pendingMode = mode;
|
|
380
1700
|
const thinkingLevel = pi.getThinkingLevel() as SessionThinkingLevel;
|
|
381
|
-
const slot: BtwSlot = {
|
|
382
|
-
question,
|
|
383
|
-
modelLabel: `${model.provider}/${model.id}`,
|
|
384
|
-
thinking: "",
|
|
385
|
-
answer: "",
|
|
386
|
-
done: false,
|
|
387
|
-
controller: new AbortController(),
|
|
388
|
-
};
|
|
389
1701
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
renderWidget(ctx);
|
|
1702
|
+
setOverlayStatus("⏳ streaming...", ctx);
|
|
1703
|
+
await ensureOverlay(ctx);
|
|
393
1704
|
|
|
394
1705
|
try {
|
|
395
|
-
|
|
396
|
-
apiKey,
|
|
397
|
-
reasoning: toReasoning(thinkingLevel),
|
|
398
|
-
signal: slot.controller.signal,
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
let response: AssistantMessage | null = null;
|
|
402
|
-
|
|
403
|
-
for await (const event of stream) {
|
|
404
|
-
if (event.type === "thinking_delta") {
|
|
405
|
-
slot.thinking += event.delta;
|
|
406
|
-
renderWidget(ctx);
|
|
407
|
-
} else if (event.type === "text_delta") {
|
|
408
|
-
slot.answer += event.delta;
|
|
409
|
-
renderWidget(ctx);
|
|
410
|
-
} else if (event.type === "done") {
|
|
411
|
-
response = event.message;
|
|
412
|
-
} else if (event.type === "error") {
|
|
413
|
-
response = event.error;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
1706
|
+
await session.prompt(question, { source: "extension" });
|
|
416
1707
|
|
|
1708
|
+
const response = getLastAssistantMessage(session);
|
|
417
1709
|
if (!response) {
|
|
418
1710
|
throw new Error("BTW request finished without a response.");
|
|
419
1711
|
}
|
|
420
1712
|
if (response.stopReason === "aborted") {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
slots.splice(slotIndex, 1);
|
|
424
|
-
renderWidget(ctx);
|
|
425
|
-
}
|
|
1713
|
+
removeTranscriptTurn(transcriptState, transcriptState.lastTurnId ?? transcriptState.currentTurnId);
|
|
1714
|
+
setOverlayStatus("Request aborted.", ctx);
|
|
426
1715
|
return;
|
|
427
1716
|
}
|
|
428
1717
|
if (response.stopReason === "error") {
|
|
429
1718
|
throw new Error(response.errorMessage || "BTW request failed.");
|
|
430
1719
|
}
|
|
431
1720
|
|
|
1721
|
+
const completedTurnId = transcriptState.lastTurnId ?? transcriptState.currentTurnId;
|
|
1722
|
+
const streamedThinking =
|
|
1723
|
+
completedTurnId !== null ? findLatestTranscriptEntry(transcriptState, completedTurnId, "thinking")?.text : "";
|
|
432
1724
|
const answer = extractAnswer(response);
|
|
433
|
-
const thinking = extractThinking(response) ||
|
|
434
|
-
slot.thinking = thinking;
|
|
435
|
-
slot.answer = answer;
|
|
436
|
-
slot.done = true;
|
|
437
|
-
renderWidget(ctx);
|
|
1725
|
+
const thinking = extractThinking(response) || streamedThinking || "";
|
|
438
1726
|
|
|
439
1727
|
const details: BtwDetails = {
|
|
440
1728
|
question,
|
|
@@ -453,27 +1741,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
453
1741
|
const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
|
|
454
1742
|
if (saveState === "saved") {
|
|
455
1743
|
notify(ctx, "Saved BTW note to the session.", "info");
|
|
1744
|
+
setOverlayStatus("Saved BTW note to the session.", ctx);
|
|
456
1745
|
} else if (saveState === "queued") {
|
|
457
1746
|
notify(ctx, "BTW note queued to save after the current turn finishes.", "info");
|
|
1747
|
+
setOverlayStatus("BTW note queued to save after the current turn finishes.", ctx);
|
|
1748
|
+
} else {
|
|
1749
|
+
setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
|
|
458
1750
|
}
|
|
459
1751
|
} catch (error) {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
1752
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1753
|
+
setTranscriptFailure(transcriptState, errorMessage);
|
|
1754
|
+
setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
|
|
1755
|
+
notify(ctx, errorMessage, "error");
|
|
1756
|
+
await disposeBtwSession();
|
|
1757
|
+
} finally {
|
|
1758
|
+
syncUi(ctx);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
468
1761
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
1762
|
+
function getPendingThreadForHandoff(): BtwHandoffExchange[] {
|
|
1763
|
+
return pendingThread.map((entry) => ({ user: entry.question, assistant: entry.answer }));
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
async function getBtwHandoffThread(
|
|
1767
|
+
ctx: ExtensionCommandContext,
|
|
1768
|
+
): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> {
|
|
1769
|
+
const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode));
|
|
1770
|
+
const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
|
|
1771
|
+
const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
|
|
1772
|
+
|
|
1773
|
+
if (resolvedThread.length === 0) {
|
|
1774
|
+
throw new Error("No BTW thread available for handoff.");
|
|
473
1775
|
}
|
|
1776
|
+
|
|
1777
|
+
return { sessionRuntime, thread: resolvedThread };
|
|
474
1778
|
}
|
|
475
1779
|
|
|
476
|
-
async function summarizeThread(ctx: ExtensionCommandContext, thread:
|
|
1780
|
+
async function summarizeThread(ctx: ExtensionCommandContext, thread: BtwHandoffExchange[]): Promise<string> {
|
|
477
1781
|
const model = ctx.model;
|
|
478
1782
|
if (!model) {
|
|
479
1783
|
throw new Error("No active model selected.");
|
|
@@ -484,32 +1788,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
484
1788
|
throw new Error(`No credentials available for ${model.provider}/${model.id}.`);
|
|
485
1789
|
}
|
|
486
1790
|
|
|
487
|
-
const
|
|
1791
|
+
const { session } = await createAgentSession({
|
|
1792
|
+
sessionManager: SessionManager.inMemory(),
|
|
488
1793
|
model,
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
content: [{ type: "text", text: formatThread(thread) }],
|
|
495
|
-
timestamp: Date.now(),
|
|
496
|
-
},
|
|
497
|
-
],
|
|
498
|
-
},
|
|
499
|
-
{
|
|
500
|
-
apiKey,
|
|
501
|
-
reasoning: "low",
|
|
502
|
-
},
|
|
503
|
-
);
|
|
1794
|
+
modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
|
|
1795
|
+
thinkingLevel: "off",
|
|
1796
|
+
tools: [],
|
|
1797
|
+
resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
|
|
1798
|
+
});
|
|
504
1799
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1800
|
+
try {
|
|
1801
|
+
await session.prompt(formatThread(thread), { source: "extension" });
|
|
1802
|
+
|
|
1803
|
+
const response = getLastAssistantMessage(session);
|
|
1804
|
+
if (!response) {
|
|
1805
|
+
throw new Error("BTW summarize finished without a response.");
|
|
1806
|
+
}
|
|
1807
|
+
if (response.stopReason === "error") {
|
|
1808
|
+
throw new Error(response.errorMessage || "Failed to summarize BTW thread.");
|
|
1809
|
+
}
|
|
1810
|
+
if (response.stopReason === "aborted") {
|
|
1811
|
+
throw new Error("BTW summarize aborted.");
|
|
1812
|
+
}
|
|
511
1813
|
|
|
512
|
-
|
|
1814
|
+
return extractAnswer(response);
|
|
1815
|
+
} finally {
|
|
1816
|
+
try {
|
|
1817
|
+
await session.abort();
|
|
1818
|
+
} catch {
|
|
1819
|
+
// Ignore abort errors during summarize session shutdown.
|
|
1820
|
+
}
|
|
1821
|
+
session.dispose();
|
|
1822
|
+
}
|
|
513
1823
|
}
|
|
514
1824
|
|
|
515
1825
|
function sendThreadToMain(ctx: ExtensionCommandContext, content: string): void {
|
|
@@ -552,123 +1862,70 @@ export default function (pi: ExtensionAPI) {
|
|
|
552
1862
|
});
|
|
553
1863
|
|
|
554
1864
|
pi.on("session_start", async (_event, ctx) => {
|
|
555
|
-
restoreThread(ctx);
|
|
1865
|
+
await restoreThread(ctx);
|
|
556
1866
|
});
|
|
557
1867
|
|
|
558
1868
|
pi.on("session_switch", async (_event, ctx) => {
|
|
559
|
-
restoreThread(ctx);
|
|
1869
|
+
await restoreThread(ctx);
|
|
560
1870
|
});
|
|
561
1871
|
|
|
562
1872
|
pi.on("session_tree", async (_event, ctx) => {
|
|
563
|
-
restoreThread(ctx);
|
|
1873
|
+
await restoreThread(ctx);
|
|
564
1874
|
});
|
|
565
1875
|
|
|
566
1876
|
pi.on("session_shutdown", async () => {
|
|
567
|
-
|
|
1877
|
+
await disposeBtwSession();
|
|
1878
|
+
dismissOverlay();
|
|
568
1879
|
});
|
|
569
1880
|
|
|
1881
|
+
for (const shortcut of BTW_FOCUS_SHORTCUTS) {
|
|
1882
|
+
pi.registerShortcut(shortcut, {
|
|
1883
|
+
description: "Toggle BTW overlay focus while leaving it open.",
|
|
1884
|
+
handler: async (_args, _ctx) => {
|
|
1885
|
+
toggleOverlayFocus();
|
|
1886
|
+
},
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
|
|
570
1890
|
pi.registerCommand("btw", {
|
|
571
|
-
description: "Continue a side conversation in a
|
|
1891
|
+
description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
|
|
572
1892
|
handler: async (args, ctx) => {
|
|
573
|
-
|
|
574
|
-
if (!question) {
|
|
575
|
-
notify(ctx, "Usage: /btw [--save] <question>", "warning");
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
if (pendingMode !== "contextual") {
|
|
580
|
-
resetThread(ctx, true, "contextual");
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
await runBtw(ctx, question, save, "contextual");
|
|
1893
|
+
await dispatchBtwCommand("btw", args, ctx);
|
|
584
1894
|
},
|
|
585
1895
|
});
|
|
586
1896
|
|
|
587
1897
|
pi.registerCommand("btw:tangent", {
|
|
588
|
-
description: "Start or continue a contextless BTW tangent
|
|
1898
|
+
description: "Start or continue a contextless BTW tangent in the focused BTW modal.",
|
|
589
1899
|
handler: async (args, ctx) => {
|
|
590
|
-
|
|
591
|
-
if (!question) {
|
|
592
|
-
notify(ctx, "Usage: /btw:tangent [--save] <question>", "warning");
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
if (pendingMode !== "tangent") {
|
|
597
|
-
resetThread(ctx, true, "tangent");
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
await runBtw(ctx, question, save, "tangent");
|
|
1900
|
+
await dispatchBtwCommand("btw:tangent", args, ctx);
|
|
601
1901
|
},
|
|
602
1902
|
});
|
|
603
1903
|
|
|
604
1904
|
pi.registerCommand("btw:new", {
|
|
605
1905
|
description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately.",
|
|
606
1906
|
handler: async (args, ctx) => {
|
|
607
|
-
|
|
608
|
-
const { question, save } = parseBtwArgs(args);
|
|
609
|
-
if (question) {
|
|
610
|
-
await runBtw(ctx, question, save, "contextual");
|
|
611
|
-
} else {
|
|
612
|
-
notify(ctx, "Started a fresh BTW thread.", "info");
|
|
613
|
-
}
|
|
1907
|
+
await dispatchBtwCommand("btw:new", args, ctx);
|
|
614
1908
|
},
|
|
615
1909
|
});
|
|
616
1910
|
|
|
617
1911
|
pi.registerCommand("btw:clear", {
|
|
618
|
-
description: "Dismiss the BTW widget and clear the current thread.",
|
|
619
|
-
handler: async (
|
|
620
|
-
|
|
621
|
-
notify(ctx, "Cleared BTW thread.", "info");
|
|
1912
|
+
description: "Dismiss the BTW modal/widget and clear the current thread.",
|
|
1913
|
+
handler: async (args, ctx) => {
|
|
1914
|
+
await dispatchBtwCommand("btw:clear", args, ctx);
|
|
622
1915
|
},
|
|
623
1916
|
});
|
|
624
1917
|
|
|
625
1918
|
pi.registerCommand("btw:inject", {
|
|
626
1919
|
description: "Inject the full BTW thread into the main agent as a user message.",
|
|
627
1920
|
handler: async (args, ctx) => {
|
|
628
|
-
|
|
629
|
-
notify(ctx, "No BTW thread to inject.", "warning");
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
const instructions = args.trim();
|
|
634
|
-
const content = instructions
|
|
635
|
-
? `Here is a side conversation I had. ${instructions}\n\n${formatThread(pendingThread)}`
|
|
636
|
-
: `Here is a side conversation I had for additional context:\n\n${formatThread(pendingThread)}`;
|
|
637
|
-
|
|
638
|
-
sendThreadToMain(ctx, content);
|
|
639
|
-
const count = pendingThread.length;
|
|
640
|
-
resetThread(ctx);
|
|
641
|
-
notify(ctx, `Injected BTW thread (${count} exchange${count === 1 ? "" : "s"}).`, "info");
|
|
1921
|
+
await dispatchBtwCommand("btw:inject", args, ctx);
|
|
642
1922
|
},
|
|
643
1923
|
});
|
|
644
1924
|
|
|
645
1925
|
pi.registerCommand("btw:summarize", {
|
|
646
1926
|
description: "Summarize the BTW thread, then inject the summary into the main agent.",
|
|
647
1927
|
handler: async (args, ctx) => {
|
|
648
|
-
|
|
649
|
-
notify(ctx, "No BTW thread to summarize.", "warning");
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
widgetStatus = "⏳ summarizing...";
|
|
654
|
-
renderWidget(ctx);
|
|
655
|
-
|
|
656
|
-
try {
|
|
657
|
-
const summary = await summarizeThread(ctx, pendingThread);
|
|
658
|
-
const instructions = args.trim();
|
|
659
|
-
const content = instructions
|
|
660
|
-
? `Here is a summary of a side conversation I had. ${instructions}\n\n${summary}`
|
|
661
|
-
: `Here is a summary of a side conversation I had:\n\n${summary}`;
|
|
662
|
-
|
|
663
|
-
sendThreadToMain(ctx, content);
|
|
664
|
-
const count = pendingThread.length;
|
|
665
|
-
resetThread(ctx);
|
|
666
|
-
notify(ctx, `Injected BTW summary (${count} exchange${count === 1 ? "" : "s"}).`, "info");
|
|
667
|
-
} catch (error) {
|
|
668
|
-
widgetStatus = null;
|
|
669
|
-
renderWidget(ctx);
|
|
670
|
-
notify(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
671
|
-
}
|
|
1928
|
+
await dispatchBtwCommand("btw:summarize", args, ctx);
|
|
672
1929
|
},
|
|
673
1930
|
});
|
|
674
1931
|
}
|