pi-btw 0.1.1 → 0.2.1

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