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/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 Theme,
11
+ type ExtensionContext,
12
+ type ResourceLoader,
7
13
  } from "@mariozechner/pi-coding-agent";
8
- import { streamSimple, type AssistantMessage, type ThinkingLevel as AiThinkingLevel } from "@mariozechner/pi-ai";
9
- import { Box, Key, Text, matchesKey } from "@mariozechner/pi-tui";
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
- class BtwAnswerOverlay {
33
- private box: Box;
70
+ type BtwResetDetails = {
71
+ timestamp: number;
72
+ mode?: BtwThreadMode;
73
+ };
34
74
 
35
- constructor(
36
- private theme: Theme,
37
- private question: string,
38
- private answer: string,
39
- private saveState: SaveState,
40
- private done: () => void,
41
- ) {
42
- this.box = this.buildBox();
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
- handleInput(data: string): void {
46
- if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
47
- this.done();
48
- }
49
- }
93
+ type BtwTranscript = BtwTranscriptEntry[];
50
94
 
51
- render(width: number): string[] {
52
- return this.box.render(width);
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
- invalidate(): void {
56
- this.box = this.buildBox();
57
- this.box.invalidate();
58
- }
104
+ type BtwSessionRuntime = {
105
+ session: AgentSession;
106
+ mode: BtwThreadMode;
107
+ subscriptions: Set<() => void>;
108
+ sideThreadStartIndex: number;
109
+ };
59
110
 
60
- dispose(): void {}
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
- private buildBox(): Box {
63
- const footer =
64
- this.saveState === "saved"
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
- const lines = [
71
- this.theme.fg("accent", this.theme.bold("[BTW]")),
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
- const box = new Box(1, 1, (text) => this.theme.bg("customMessageBg", text));
84
- box.addChild(new Text(lines.join("\n"), 0, 0));
85
- return box;
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 isBtwMessage(message: { role: string; customType?: string }): boolean {
90
- return message.role === "custom" && message.customType === BTW_MESSAGE_TYPE;
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 toReasoning(level: SessionThinkingLevel): AiThinkingLevel | undefined {
94
- return level === "off" ? undefined : level;
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
- const text = message.content
99
- .filter((part): part is { type: "text"; text: string } => part.type === "text")
100
- .map((part) => part.text)
101
- .join("\n")
102
- .trim();
171
+ return extractText(message.content, "text") || "(No text response)";
172
+ }
103
173
 
104
- return text || "(No text response)";
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 buildBtwContext(ctx: ExtensionCommandContext, question: string) {
114
- const sessionContext = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
115
- const messages = sessionContext.messages.filter((message) => !isBtwMessage(message));
184
+ function buildBtwSeedState(
185
+ ctx: ExtensionCommandContext,
186
+ thread: BtwDetails[],
187
+ mode: BtwThreadMode,
188
+ ): { messages: Message[]; sideThreadStartIndex: number } {
189
+ const messages: Message[] = [];
116
190
 
117
- return {
118
- systemPrompt: ctx.getSystemPrompt(),
119
- messages: [
120
- ...messages,
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: "user" as const,
123
- content: [{ type: "text" as const, text: question }],
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
- async function runBtw(
131
- ctx: ExtensionCommandContext,
132
- question: string,
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
- const apiKey = await ctx.modelRegistry.getApiKey(model);
142
- if (!apiKey) {
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
- const stream = streamSimple(model, buildBtwContext(ctx, question), {
147
- apiKey,
148
- reasoning: toReasoning(thinkingLevel),
149
- signal,
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 response = await stream.result();
153
- if (response.stopReason === "aborted") {
154
- return null;
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
- if (response.stopReason === "error") {
157
- throw new Error(response.errorMessage || "BTW request failed.");
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
- return response;
361
+ state.lastTurnId = resolvedTurnId;
362
+ if (state.currentTurnId === resolvedTurnId) {
363
+ state.currentTurnId = null;
364
+ }
161
365
  }
162
366
 
163
- function buildBtwMessageContent(question: string, answer: string): string {
164
- return `Q: ${question}\n\nA: ${answer}`;
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 saveBtwNote(
168
- pi: ExtensionAPI,
169
- details: BtwDetails,
170
- saveRequested: boolean,
171
- wasBusy: boolean,
172
- ): SaveState {
173
- if (!saveRequested) {
174
- return "not-saved";
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
- const message = {
178
- customType: BTW_MESSAGE_TYPE,
179
- content: buildBtwMessageContent(details.question, details.answer),
180
- display: true,
181
- details,
182
- };
399
+ return undefined;
400
+ }
183
401
 
184
- if (wasBusy) {
185
- pi.sendMessage(message, { deliverAs: "followUp" });
186
- return "queued";
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
- pi.sendMessage(message);
190
- return "saved";
410
+ return ensureTranscriptTurn(state);
191
411
  }
192
412
 
193
- async function showBtwAnswer(
194
- ctx: ExtensionCommandContext,
195
- question: string,
196
- answer: string,
197
- saveState: SaveState,
198
- ): Promise<void> {
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
- await ctx.ui.custom<void>(
204
- (_tui, theme, _kb, done) => new BtwAnswerOverlay(theme, question, answer, saveState, () => done(undefined)),
205
- {
206
- overlay: true,
207
- overlayOptions: {
208
- width: "70%",
209
- maxHeight: "80%",
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
- export default function (pi: ExtensionAPI) {
219
- pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
220
- const details = message.details as BtwDetails | undefined;
221
- const content = typeof message.content === "string" ? message.content : "[non-text btw message]";
222
- const lines = [theme.fg("accent", theme.bold("[BTW]")), content];
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
- if (expanded && details) {
225
- lines.push(
226
- theme.fg(
227
- "dim",
228
- `model: ${details.provider}/${details.model} · thinking: ${details.thinkingLevel}`,
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
- if (details.usage) {
233
- lines.push(
234
- theme.fg(
235
- "dim",
236
- `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`,
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
- const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
243
- box.addChild(new Text(lines.join("\n"), 0, 0));
244
- return box;
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
- pi.on("context", async (event) => {
248
- return {
249
- messages: event.messages.filter((message) => !isBtwMessage(message)),
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
- pi.registerCommand("btw", {
254
- description: "Ask a side question now. Add --save to persist the answer in the session.",
255
- handler: async (args, ctx) => {
256
- const { question, save } = parseBtwArgs(args);
257
- if (!question) {
258
- if (ctx.hasUI) {
259
- ctx.ui.notify("Usage: /btw [--save] <question>", "warning");
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 (!ctx.model) {
265
- if (ctx.hasUI) {
266
- ctx.ui.notify("No active model selected.", "error");
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 wasBusy = !ctx.isIdle();
272
- const model = ctx.model;
273
- const thinkingLevel = pi.getThinkingLevel() as SessionThinkingLevel;
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
- try {
276
- let response: AssistantMessage | null;
277
-
278
- if (ctx.hasUI) {
279
- response = await ctx.ui.custom<AssistantMessage | null>(
280
- (tui, theme, _kb, done) => {
281
- const status = wasBusy ? "in parallel with the current turn" : "now";
282
- const loader = new BorderedLoader(
283
- tui,
284
- theme,
285
- `Running /btw ${status} with ${model.provider}/${model.id} (${thinkingLevel})...`,
286
- { cancellable: true },
287
- );
288
-
289
- loader.onAbort = () => done(null);
290
-
291
- runBtw(ctx, question, thinkingLevel, loader.signal)
292
- .then(done)
293
- .catch((error) => {
294
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
295
- done(null);
296
- });
297
-
298
- return loader;
299
- },
300
- {
301
- overlay: true,
302
- overlayOptions: {
303
- width: "60%",
304
- minWidth: 40,
305
- anchor: "center",
306
- margin: 1,
307
- },
308
- },
309
- );
310
- } else {
311
- response = await runBtw(ctx, question, thinkingLevel);
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
- if (!response) {
315
- if (ctx.hasUI) {
316
- ctx.ui.notify("/btw cancelled", "info");
317
- }
318
- return;
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
- const answer = extractAnswer(response);
322
- const details: BtwDetails = {
323
- question,
324
- answer,
325
- provider: model.provider,
326
- model: model.id,
327
- thinkingLevel,
328
- timestamp: Date.now(),
329
- usage: response.usage,
330
- };
331
-
332
- const saveState = saveBtwNote(pi, details, save, wasBusy);
333
-
334
- if (ctx.hasUI) {
335
- await showBtwAnswer(ctx, question, answer, saveState);
336
-
337
- if (saveState === "saved") {
338
- ctx.ui.notify("Saved BTW note to the session.", "info");
339
- } else if (saveState === "queued") {
340
- ctx.ui.notify("BTW note queued to save after the current turn finishes.", "info");
341
- }
342
- }
343
- } catch (error) {
344
- if (ctx.hasUI) {
345
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
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
  }