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