pi-btw 0.4.1 → 0.5.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
@@ -2,20 +2,29 @@ import {
2
2
  buildSessionContext,
3
3
  createAgentSession,
4
4
  createExtensionRuntime,
5
+ getMarkdownTheme,
6
+ ModelRuntime,
5
7
  SessionManager,
6
8
  type AgentSession,
9
+ type CreateAgentSessionOptions,
7
10
  type AgentSessionEvent,
8
11
  type ExtensionAPI,
9
12
  type ExtensionCommandContext,
10
13
  type ExtensionContext,
11
14
  type ResourceLoader,
12
15
  } from "@earendil-works/pi-coding-agent";
13
- import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel, type UserMessage } from "@earendil-works/pi-ai";
16
+ import {
17
+ type AssistantMessage,
18
+ type Message,
19
+ type ThinkingLevel as AiThinkingLevel,
20
+ type UserMessage,
21
+ } from "@earendil-works/pi-ai";
14
22
  import {
15
23
  Box,
16
24
  Container,
17
25
  Input,
18
26
  Key,
27
+ Markdown,
19
28
  Text,
20
29
  matchesKey,
21
30
  truncateToWidth,
@@ -23,7 +32,10 @@ import {
23
32
  wrapTextWithAnsi,
24
33
  type Focusable,
25
34
  type KeybindingsManager,
35
+ type KeyId,
36
+ type MarkdownTheme,
26
37
  type OverlayHandle,
38
+ type OverlayOptions,
27
39
  type TUI,
28
40
  } from "@earendil-works/pi-tui";
29
41
 
@@ -32,12 +44,123 @@ const BTW_ENTRY_TYPE = "btw-thread-entry";
32
44
  const BTW_RESET_TYPE = "btw-thread-reset";
33
45
  const BTW_MODEL_OVERRIDE_TYPE = "btw-model-override";
34
46
  const BTW_THINKING_OVERRIDE_TYPE = "btw-thinking-override";
35
- const BTW_FOCUS_SHORTCUTS = [Key.alt("/"), Key.ctrlAlt("w")] as const;
47
+ const BTW_DEFAULT_FOCUS_SHORTCUTS: readonly KeyId[] = [Key.alt("/"), Key.super("/"), Key.ctrlAlt("w")];
48
+ const BTW_FOCUS_KEYS_ENV = "PI_BTW_FOCUS_KEYS";
49
+ const BTW_FOCUS_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
50
+ // Mirrors the SpecialKey union in @earendil-works/pi-tui keys.d.ts (lower-cased).
51
+ const BTW_FOCUS_SPECIAL_KEYS = new Set([
52
+ "escape", "esc", "enter", "return", "tab", "space", "backspace", "delete", "insert", "clear",
53
+ "home", "end", "pageup", "pagedown", "up", "down", "left", "right",
54
+ "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
55
+ ]);
56
+ // Symbols from the SymbolKey union (letters/digits are matched directly).
57
+ const BTW_FOCUS_SYMBOL_KEYS = new Set([
58
+ "`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/", "!", "@", "#", "$", "%", "^", "&", "*",
59
+ "(", ")", "_", "+", "|", "~", "{", "}", ":", "<", ">", "?",
60
+ ]);
61
+
62
+ /**
63
+ * Resolve the BTW overlay focus-toggle shortcuts.
64
+ *
65
+ * Users whose window manager or terminal claims the default shortcuts can override them by
66
+ * setting PI_BTW_FOCUS_KEYS to a comma-separated list of pi-tui key identifiers
67
+ * (e.g. "ctrl+/,ctrl+alt+b"). Blank, duplicate, or unparseable entries are ignored; if no
68
+ * usable entries remain, the defaults are kept so focus toggling never becomes impossible.
69
+ */
70
+ export function resolveBtwFocusShortcuts(env: NodeJS.ProcessEnv = process.env): KeyId[] {
71
+ const raw = env[BTW_FOCUS_KEYS_ENV];
72
+ if (typeof raw !== "string" || raw.trim() === "") {
73
+ return [...BTW_DEFAULT_FOCUS_SHORTCUTS];
74
+ }
75
+
76
+ const seen = new Set<string>();
77
+ const shortcuts: KeyId[] = [];
78
+ for (const part of raw.split(",")) {
79
+ const candidate = part.trim().toLowerCase();
80
+ if (!candidate || seen.has(candidate) || !isValidFocusShortcut(candidate)) {
81
+ continue;
82
+ }
83
+ seen.add(candidate);
84
+ shortcuts.push(candidate as KeyId);
85
+ }
86
+
87
+ return shortcuts.length > 0 ? shortcuts : [...BTW_DEFAULT_FOCUS_SHORTCUTS];
88
+ }
89
+
90
+ /**
91
+ * Validate a candidate against the pi-tui KeyId grammar: zero or more distinct recognized
92
+ * modifiers followed by exactly one base key (letter, digit, symbol, or named special key).
93
+ * Rejects typos like "cmd+/" or "control+x" and duplicate/empty segments.
94
+ */
95
+ export function isValidFocusShortcut(candidate: string): boolean {
96
+ const segments = candidate.split("+");
97
+ const base = segments.pop();
98
+ if (base === undefined || !isValidFocusBaseKey(base)) {
99
+ return false;
100
+ }
101
+
102
+ const seen = new Set<string>();
103
+ for (const segment of segments) {
104
+ if (!BTW_FOCUS_MODIFIERS.has(segment) || seen.has(segment)) {
105
+ return false;
106
+ }
107
+ seen.add(segment);
108
+ }
109
+
110
+ return true;
111
+ }
112
+
113
+ function isValidFocusBaseKey(base: string): boolean {
114
+ if (base.length === 1) {
115
+ return /[a-z0-9]/.test(base) || BTW_FOCUS_SYMBOL_KEYS.has(base);
116
+ }
117
+ return BTW_FOCUS_SPECIAL_KEYS.has(base);
118
+ }
119
+
120
+ function formatFocusShortcutLabel(shortcut: KeyId): string {
121
+ return shortcut
122
+ .split("+")
123
+ .map((segment) => {
124
+ switch (segment) {
125
+ case "ctrl":
126
+ return "Ctrl";
127
+ case "alt":
128
+ return "Alt";
129
+ case "shift":
130
+ return "Shift";
131
+ case "super":
132
+ return "Super";
133
+ default:
134
+ return segment.length === 1 ? segment.toUpperCase() : segment;
135
+ }
136
+ })
137
+ .join("+");
138
+ }
139
+
140
+ export function describeFocusShortcuts(shortcuts: readonly KeyId[]): string {
141
+ const labels = shortcuts.map(formatFocusShortcutLabel);
142
+ if (labels.length <= 1) {
143
+ return labels[0] ?? "";
144
+ }
145
+ return `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`;
146
+ }
147
+
148
+ const BTW_FOCUS_SHORTCUTS: readonly KeyId[] = resolveBtwFocusShortcuts();
149
+ const BTW_FOCUS_SHORTCUTS_LABEL = describeFocusShortcuts(BTW_FOCUS_SHORTCUTS);
36
150
 
37
151
  function matchesBtwFocusShortcut(data: string): boolean {
38
152
  return BTW_FOCUS_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut));
39
153
  }
40
154
 
155
+ /** Toggles the overlay between framed "window" width and edge-to-edge "full" width. */
156
+ const BTW_WIDTH_TOGGLE_SHORTCUT: KeyId = Key.alt("w");
157
+
158
+ function matchesBtwWidthToggle(data: string): boolean {
159
+ return matchesKey(data, BTW_WIDTH_TOGGLE_SHORTCUT);
160
+ }
161
+
162
+ type BtwOverlayWidthMode = "window" | "full";
163
+
41
164
  const BTW_SYSTEM_PROMPT = [
42
165
  "You are having an aside conversation with the user, separate from their main working session.",
43
166
  "If main session messages are provided, they are for context only — that work is being handled by another agent.",
@@ -109,8 +232,10 @@ type ResolvedBtwSettings = {
109
232
  fallbackReason?: string;
110
233
  };
111
234
 
235
+ type BtwTurnOutcome = "completed" | "aborted" | "failed";
236
+
112
237
  type BtwTranscriptEntry =
113
- | { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end" }
238
+ | { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end"; outcome?: BtwTurnOutcome }
114
239
  | { id: number; turnId: number; type: "user-message"; text: string }
115
240
  | { id: number; turnId: number; type: "thinking"; text: string; streaming: boolean }
116
241
  | { id: number; turnId: number; type: "assistant-text"; text: string; streaming: boolean }
@@ -143,6 +268,8 @@ type BtwSessionRuntime = {
143
268
  mode: BtwThreadMode;
144
269
  subscriptions: Set<() => void>;
145
270
  sideThreadStartIndex: number;
271
+ abortPromise?: Promise<void>;
272
+ promptQueue: Promise<void>;
146
273
  };
147
274
 
148
275
  type OverlayRuntime = {
@@ -176,17 +303,73 @@ function createBtwResourceLoader(
176
303
  const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
177
304
  const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
178
305
 
179
- return {
306
+ const resourceLoader: ResourceLoader = {
180
307
  getExtensions: () => extensionsResult,
181
308
  getSkills: () => ({ skills: [], diagnostics: [] }),
182
309
  getPrompts: () => ({ prompts: [], diagnostics: [] }),
183
310
  getThemes: () => ({ themes: [], diagnostics: [] }),
184
311
  getAgentsFiles: () => ({ agentsFiles: [] }),
185
312
  getSystemPrompt: () => systemPrompt,
313
+ getSystemPromptSource: () => undefined,
186
314
  getAppendSystemPrompt: () => appendSystemPrompt,
315
+ getAppendSystemPromptSources: () => [],
187
316
  extendResources: () => {},
188
- reload: async () => {},
317
+ reload: async (_options) => {},
189
318
  };
319
+
320
+ return resourceLoader;
321
+ }
322
+
323
+ async function createBtwModelRuntimeOptions(
324
+ ctx: ExtensionCommandContext,
325
+ model: SessionModel,
326
+ ): Promise<Pick<CreateAgentSessionOptions, "modelRuntime">> {
327
+ const nativeProvider = ctx.modelRegistry.getRegisteredNativeProvider(model.provider);
328
+ const providerConfig = ctx.modelRegistry.getRegisteredProviderConfig(model.provider);
329
+ const hasRuntimeApiKey = ctx.modelRegistry.getProviderAuthStatus(model.provider).source === "runtime";
330
+
331
+ if (!nativeProvider && !providerConfig && !hasRuntimeApiKey) {
332
+ return {};
333
+ }
334
+
335
+ const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false });
336
+ if (nativeProvider) {
337
+ modelRuntime.registerNativeProvider(nativeProvider);
338
+ } else if (providerConfig) {
339
+ modelRuntime.registerProvider(model.provider, providerConfig);
340
+ }
341
+ await modelRuntime.refresh({ allowNetwork: false });
342
+
343
+ // --api-key is stored only in the parent runtime.
344
+ if (hasRuntimeApiKey) {
345
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
346
+ if (auth.ok && auth.apiKey) {
347
+ await modelRuntime.setRuntimeApiKey(model.provider, auth.apiKey);
348
+ }
349
+ }
350
+
351
+ return { modelRuntime };
352
+ }
353
+
354
+ function hasResolvedAuthValues(values?: Record<string, string | null | undefined>): boolean {
355
+ return !!values && Object.values(values).some((value) => typeof value === "string" && value.length > 0);
356
+ }
357
+
358
+ function hasUsableModelAuth(
359
+ ctx: ExtensionCommandContext,
360
+ model: SessionModel,
361
+ auth: Awaited<ReturnType<ExtensionCommandContext["modelRegistry"]["getApiKeyAndHeaders"]>>,
362
+ ): boolean {
363
+ if (!auth.ok) {
364
+ return false;
365
+ }
366
+
367
+ return (
368
+ !!auth.apiKey ||
369
+ hasResolvedAuthValues(auth.headers) ||
370
+ hasResolvedAuthValues(auth.env) ||
371
+ ctx.modelRegistry.hasConfiguredAuth(model)
372
+ );
190
373
  }
191
374
 
192
375
  function extractText(parts: AssistantMessage["content"], type: "text" | "thinking"): string {
@@ -415,17 +598,29 @@ function ensureTranscriptTurn(state: BtwTranscriptState): number {
415
598
  return turnId;
416
599
  }
417
600
 
418
- function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null): void {
601
+ function finishTranscriptTurn(
602
+ state: BtwTranscriptState,
603
+ turnId?: number | null,
604
+ outcome: BtwTurnOutcome = "completed",
605
+ ): void {
419
606
  const resolvedTurnId = turnId ?? state.currentTurnId;
420
607
  if (resolvedTurnId === null || resolvedTurnId === undefined) {
421
608
  return;
422
609
  }
423
610
 
424
- const hasEndBoundary = state.entries.some(
425
- (entry) => entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
611
+ const endBoundary = state.entries.find(
612
+ (entry): entry is Extract<BtwTranscriptEntry, { type: "turn-boundary" }> =>
613
+ entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
426
614
  );
427
- if (!hasEndBoundary) {
428
- appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
615
+ if (endBoundary) {
616
+ endBoundary.outcome = outcome;
617
+ } else {
618
+ appendTranscriptEntry(state, {
619
+ type: "turn-boundary",
620
+ turnId: resolvedTurnId,
621
+ phase: "end",
622
+ outcome,
623
+ } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
429
624
  }
430
625
 
431
626
  for (const entry of state.entries) {
@@ -444,26 +639,6 @@ function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null)
444
639
  }
445
640
  }
446
641
 
447
- function removeTranscriptTurn(state: BtwTranscriptState, turnId: number | null): void {
448
- if (turnId === null) {
449
- return;
450
- }
451
-
452
- state.entries = state.entries.filter((entry) => entry.turnId !== turnId);
453
- for (const [toolCallId, toolCall] of state.toolCalls.entries()) {
454
- if (toolCall.turnId === turnId) {
455
- state.toolCalls.delete(toolCallId);
456
- }
457
- }
458
-
459
- if (state.currentTurnId === turnId) {
460
- state.currentTurnId = null;
461
- }
462
- if (state.lastTurnId === turnId) {
463
- state.lastTurnId = null;
464
- }
465
- }
466
-
467
642
  function findLatestTranscriptEntry<TType extends BtwTranscriptEntry["type"]>(
468
643
  state: BtwTranscriptState,
469
644
  turnId: number,
@@ -746,7 +921,10 @@ function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEven
746
921
  return;
747
922
  }
748
923
  case "turn_end": {
749
- finishTranscriptTurn(state);
924
+ const stopReason = event.message.role === "assistant" ? event.message.stopReason : "stop";
925
+ const outcome: BtwTurnOutcome =
926
+ stopReason === "aborted" ? "aborted" : stopReason === "error" ? "failed" : "completed";
927
+ finishTranscriptTurn(state, undefined, outcome);
750
928
  return;
751
929
  }
752
930
  default:
@@ -767,7 +945,7 @@ function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDe
767
945
  function setTranscriptFailure(state: BtwTranscriptState, message: string): void {
768
946
  const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state);
769
947
  upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false);
770
- finishTranscriptTurn(state, turnId);
948
+ finishTranscriptTurn(state, turnId, "failed");
771
949
  }
772
950
 
773
951
  function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
@@ -779,10 +957,26 @@ function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
779
957
  }
780
958
 
781
959
  function getCompletedExchangeCount(entries: BtwTranscript): number {
782
- return entries.filter((entry) => entry.type === "assistant-text" && !entry.streaming).length;
960
+ const completedTurnIds = new Set(
961
+ entries.flatMap((entry) =>
962
+ entry.type === "turn-boundary" &&
963
+ entry.phase === "end" &&
964
+ (entry.outcome === undefined || entry.outcome === "completed")
965
+ ? [entry.turnId]
966
+ : [],
967
+ ),
968
+ );
969
+ return entries.filter(
970
+ (entry) => entry.type === "assistant-text" && !entry.streaming && completedTurnIds.has(entry.turnId),
971
+ ).length;
783
972
  }
784
973
 
785
- function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext["ui"]["theme"]): string[] {
974
+ function buildOverlayTranscript(
975
+ entries: BtwTranscript,
976
+ theme: ExtensionContext["ui"]["theme"],
977
+ markdownTheme: MarkdownTheme,
978
+ contentWidth: number,
979
+ ): string[] {
786
980
  if (entries.length === 0) {
787
981
  return [theme.fg("dim", "No BTW thread yet. Ask a side question to start one.")];
788
982
  }
@@ -793,7 +987,7 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
793
987
  const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning");
794
988
  const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success");
795
989
  const separator = theme.fg("borderMuted", "────────────────────────────────────────");
796
- const blockIndent = " ";
990
+ const blockIndent = BTW_BLOCK_INDENT;
797
991
  const resultIndent = blockIndent;
798
992
 
799
993
  const pushBlankLine = () => {
@@ -854,9 +1048,17 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
854
1048
 
855
1049
  if (entry.type === "thinking") {
856
1050
  const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge;
857
- pushStackedBlock(thinkingHeader, entry.text, {
858
- style: (line) => theme.fg("warning", theme.italic(line)),
859
- });
1051
+ const markdownLines = new Markdown(entry.text, 0, 0, markdownTheme, {
1052
+ color: (text: string) => theme.fg("warning", text),
1053
+ italic: true,
1054
+ })
1055
+ .render(Math.max(1, contentWidth))
1056
+ .map((line) => line.replace(/\s+$/u, ""));
1057
+ pushBlankLine();
1058
+ lines.push(thinkingHeader);
1059
+ for (const line of markdownLines) {
1060
+ lines.push(line ? `${blockIndent}${line}` : "");
1061
+ }
860
1062
  continue;
861
1063
  }
862
1064
 
@@ -884,7 +1086,14 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
884
1086
 
885
1087
  if (entry.type === "assistant-text") {
886
1088
  const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge;
887
- pushStackedBlock(assistantHeader, entry.text);
1089
+ const markdownLines = new Markdown(entry.text, 0, 0, markdownTheme)
1090
+ .render(Math.max(1, contentWidth))
1091
+ .map((line) => line.replace(/\s+$/u, ""));
1092
+ pushBlankLine();
1093
+ lines.push(assistantHeader);
1094
+ for (const line of markdownLines) {
1095
+ lines.push(line ? `${blockIndent}${line}` : "");
1096
+ }
888
1097
  }
889
1098
  }
890
1099
 
@@ -908,7 +1117,7 @@ type BtwHandoffExchange = {
908
1117
  };
909
1118
 
910
1119
  function buildBtwMessageContent(question: string, answer: string): string {
911
- return `Q: ${question}\n\nA: ${answer}`;
1120
+ return `**Question**\n\n${question}\n\n**Answer**\n\n${answer}`;
912
1121
  }
913
1122
 
914
1123
  function formatThread(thread: BtwHandoffExchange[]): string {
@@ -932,18 +1141,18 @@ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffE
932
1141
  const exchanges: BtwHandoffExchange[] = [];
933
1142
  let currentUser = "";
934
1143
  let currentAssistant = "";
1144
+ let excludeCurrent = false;
935
1145
 
936
1146
  const pushCurrent = () => {
937
- if (!currentUser && !currentAssistant) {
938
- return;
1147
+ if (!excludeCurrent && (currentUser || currentAssistant)) {
1148
+ exchanges.push({
1149
+ user: currentUser.trim() || "(No user prompt)",
1150
+ assistant: currentAssistant.trim() || "(No assistant response)",
1151
+ });
939
1152
  }
940
-
941
- exchanges.push({
942
- user: currentUser.trim() || "(No user prompt)",
943
- assistant: currentAssistant.trim() || "(No assistant response)",
944
- });
945
1153
  currentUser = "";
946
1154
  currentAssistant = "";
1155
+ excludeCurrent = false;
947
1156
  };
948
1157
 
949
1158
  for (const message of threadMessages) {
@@ -951,18 +1160,25 @@ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffE
951
1160
  continue;
952
1161
  }
953
1162
 
954
- const text = extractMessageText(message).trim();
955
- if (!text) {
956
- continue;
957
- }
958
-
959
1163
  if (message.role === "user") {
1164
+ const text = extractMessageText(message).trim();
1165
+ if (!text) {
1166
+ continue;
1167
+ }
960
1168
  pushCurrent();
961
1169
  currentUser = text;
962
1170
  continue;
963
1171
  }
964
1172
 
965
- currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
1173
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
1174
+ excludeCurrent = true;
1175
+ continue;
1176
+ }
1177
+
1178
+ const text = extractMessageText(message).trim();
1179
+ if (text) {
1180
+ currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
1181
+ }
966
1182
  }
967
1183
 
968
1184
  pushCurrent();
@@ -995,6 +1211,17 @@ function saveVisibleBtwNote(
995
1211
  return "saved";
996
1212
  }
997
1213
 
1214
+ function canRenderBtwOverlay(ctx: ExtensionContext | ExtensionCommandContext): boolean {
1215
+ return ctx.hasUI && ctx.mode === "tui";
1216
+ }
1217
+
1218
+ function notifyInlineQuestionRequired(
1219
+ ctx: ExtensionCommandContext,
1220
+ command: "/btw" | "/btw:tangent" | "/btw:new",
1221
+ ): void {
1222
+ notify(ctx, `${command} cannot open its composer outside Pi's TUI. Pass the question inline instead.`, "warning");
1223
+ }
1224
+
998
1225
  function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
999
1226
  if (ctx.hasUI) {
1000
1227
  ctx.ui.notify(message, level);
@@ -1003,6 +1230,8 @@ function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string
1003
1230
 
1004
1231
  /** Fixed overlay rows outside the transcript viewport (must match render() structure). */
1005
1232
  const BTW_OVERLAY_CHROME_LINES = 9;
1233
+ /** Indent applied to transcript block bodies. */
1234
+ const BTW_BLOCK_INDENT = " ";
1006
1235
 
1007
1236
  function getOverlayTitle(mode: BtwThreadMode): string {
1008
1237
  return mode === "tangent" ? "BTW tangent" : "BTW";
@@ -1027,14 +1256,19 @@ class BtwOverlayComponent extends Container implements Focusable {
1027
1256
  private readonly readTranscriptEntries: () => BtwTranscript;
1028
1257
  private readonly getStatus: () => string | null;
1029
1258
  private readonly getMode: () => BtwThreadMode;
1259
+ private readonly getWidthMode: () => BtwOverlayWidthMode;
1030
1260
  private readonly onSubmitCallback: (value: string) => void;
1031
1261
  private readonly onDismissCallback: () => void;
1032
1262
  private readonly onUnfocusCallback: () => void;
1263
+ private readonly onToggleWidthCallback: () => void;
1033
1264
  private readonly tui: TUI;
1034
1265
  private readonly theme: ExtensionContext["ui"]["theme"];
1266
+ private readonly markdownTheme: MarkdownTheme;
1267
+ private readonly managesMouseReporting: boolean;
1035
1268
  private transcriptLines: string[] = [];
1036
1269
  private transcriptScrollOffset = 0;
1037
1270
  private transcriptViewportHeight = 8;
1271
+ private contentWidth = 66;
1038
1272
  private followTranscript = true;
1039
1273
  private _focused = false;
1040
1274
  private modeTextValue = "";
@@ -1058,19 +1292,27 @@ class BtwOverlayComponent extends Container implements Focusable {
1058
1292
  readTranscriptEntries: () => BtwTranscript,
1059
1293
  getStatus: () => string | null,
1060
1294
  getMode: () => BtwThreadMode,
1295
+ getWidthMode: () => BtwOverlayWidthMode,
1061
1296
  onSubmit: (value: string) => void,
1062
1297
  onDismiss: () => void,
1063
1298
  onUnfocus: () => void,
1299
+ onToggleWidth: () => void,
1064
1300
  ) {
1065
1301
  super();
1066
1302
  this.tui = tui;
1067
1303
  this.theme = theme;
1304
+ this.markdownTheme = getMarkdownTheme();
1305
+ // Fullscreen Pi owns mouse reporting for the entire terminal session. In
1306
+ // regular mode BTW manages it while the overlay exists.
1307
+ this.managesMouseReporting = tui.mode !== "fullscreen";
1068
1308
  this.readTranscriptEntries = readTranscriptEntries;
1069
1309
  this.getStatus = getStatus;
1070
1310
  this.getMode = getMode;
1311
+ this.getWidthMode = getWidthMode;
1071
1312
  this.onSubmitCallback = onSubmit;
1072
1313
  this.onDismissCallback = onDismiss;
1073
1314
  this.onUnfocusCallback = onUnfocus;
1315
+ this.onToggleWidthCallback = onToggleWidth;
1074
1316
 
1075
1317
  this.modeText = new Text("", 1, 0);
1076
1318
  this.summaryText = new Text("", 1, 0);
@@ -1088,8 +1330,9 @@ class BtwOverlayComponent extends Container implements Focusable {
1088
1330
 
1089
1331
  this.hintsText = new Text("", 1, 0);
1090
1332
 
1091
- // Enable SGR mouse reporting so wheel/touchpad events reach handleInput().
1092
- this.tui.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
1333
+ if (this.managesMouseReporting) {
1334
+ this.tui.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
1335
+ }
1093
1336
 
1094
1337
  const originalHandleInput = this.input.handleInput.bind(this.input);
1095
1338
  this.input.handleInput = (data: string) => {
@@ -1114,17 +1357,33 @@ class BtwOverlayComponent extends Container implements Focusable {
1114
1357
  this.refresh();
1115
1358
  }
1116
1359
 
1360
+ private get borderless(): boolean {
1361
+ // Full-width mode drops the vertical bars and corner glyphs so a terminal
1362
+ // Shift+drag selection captures only the dialog's own text — with side
1363
+ // borders, the leftmost/rightmost columns would land inside the drag.
1364
+ return this.getWidthMode() === "full";
1365
+ }
1366
+
1117
1367
  private frameLine(content: string, innerWidth: number): string {
1118
1368
  const truncated = truncateToWidth(content, innerWidth, "");
1119
1369
  const padding = Math.max(0, innerWidth - visibleWidth(truncated));
1370
+ if (this.borderless) {
1371
+ return `${truncated}${" ".repeat(padding)}`;
1372
+ }
1120
1373
  return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
1121
1374
  }
1122
1375
 
1123
1376
  private ruleLine(innerWidth: number): string {
1377
+ if (this.borderless) {
1378
+ return this.theme.fg("border", "─".repeat(innerWidth));
1379
+ }
1124
1380
  return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
1125
1381
  }
1126
1382
 
1127
1383
  private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
1384
+ if (this.borderless) {
1385
+ return this.theme.fg("border", "─".repeat(innerWidth));
1386
+ }
1128
1387
  const left = edge === "top" ? "┌" : "└";
1129
1388
  const right = edge === "top" ? "┐" : "┘";
1130
1389
  return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
@@ -1156,7 +1415,9 @@ class BtwOverlayComponent extends Container implements Focusable {
1156
1415
  }
1157
1416
 
1158
1417
  dispose(): void {
1159
- this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
1418
+ if (this.managesMouseReporting) {
1419
+ this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
1420
+ }
1160
1421
  }
1161
1422
 
1162
1423
  private getMouseScrollDelta(data: string): number | null {
@@ -1179,6 +1440,11 @@ class BtwOverlayComponent extends Container implements Focusable {
1179
1440
  return;
1180
1441
  }
1181
1442
 
1443
+ if (matchesBtwWidthToggle(data)) {
1444
+ this.onToggleWidthCallback();
1445
+ return;
1446
+ }
1447
+
1182
1448
  const mouseScrollDelta = this.getMouseScrollDelta(data);
1183
1449
  if (mouseScrollDelta !== null) {
1184
1450
  this.scrollTranscript(mouseScrollDelta);
@@ -1201,7 +1467,8 @@ class BtwOverlayComponent extends Container implements Focusable {
1201
1467
  }
1202
1468
 
1203
1469
  private inputFrameLine(dialogWidth: number): string {
1204
- const targetWidth = Math.max(1, dialogWidth - 2);
1470
+ const borderColumns = this.borderless ? 0 : 2;
1471
+ const targetWidth = Math.max(1, dialogWidth - borderColumns);
1205
1472
  const previousFocused = this.input.focused;
1206
1473
  // Input.render() emits CURSOR_MARKER when focused. In overlay mode that APC marker
1207
1474
  // can skew width/composition on this one row before the TUI strips it, producing a
@@ -1212,6 +1479,9 @@ class BtwOverlayComponent extends Container implements Focusable {
1212
1479
  const renderedInputLine = this.input.render(targetWidth)[0] ?? "";
1213
1480
  const inputLine = truncateToWidth(renderedInputLine, targetWidth, "");
1214
1481
  const padding = Math.max(0, targetWidth - visibleWidth(inputLine));
1482
+ if (this.borderless) {
1483
+ return `${inputLine}${" ".repeat(padding)}`;
1484
+ }
1215
1485
  return `${this.theme.fg("border", "│")}${inputLine}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
1216
1486
  } finally {
1217
1487
  this.input.focused = previousFocused;
@@ -1224,7 +1494,13 @@ class BtwOverlayComponent extends Container implements Focusable {
1224
1494
 
1225
1495
  override render(width: number): string[] {
1226
1496
  const dialogWidth = Math.max(24, width);
1227
- const innerWidth = Math.max(22, dialogWidth - 2);
1497
+ const borderColumns = this.borderless ? 0 : 2;
1498
+ const innerWidth = Math.max(22, dialogWidth - borderColumns);
1499
+ const contentWidth = Math.max(1, innerWidth - BTW_BLOCK_INDENT.length);
1500
+ if (contentWidth !== this.contentWidth) {
1501
+ this.contentWidth = contentWidth;
1502
+ this.rebuildTranscriptLines();
1503
+ }
1228
1504
  const transcriptLines = this.wrapTranscript(innerWidth);
1229
1505
  const dialogHeight = this.getDialogHeight();
1230
1506
  const chromeHeight = BTW_OVERLAY_CHROME_LINES;
@@ -1288,6 +1564,15 @@ class BtwOverlayComponent extends Container implements Focusable {
1288
1564
  return this.readTranscriptEntries().map((entry) => ({ ...entry }));
1289
1565
  }
1290
1566
 
1567
+ private rebuildTranscriptLines(): void {
1568
+ this.transcriptLines = buildOverlayTranscript(
1569
+ this.readTranscriptEntries(),
1570
+ this.theme,
1571
+ this.markdownTheme,
1572
+ this.contentWidth,
1573
+ );
1574
+ }
1575
+
1291
1576
  refresh(): void {
1292
1577
  this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
1293
1578
  this.modeText.setText(this.modeTextValue);
@@ -1297,7 +1582,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1297
1582
  this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`;
1298
1583
  this.summaryText.setText(this.summaryTextValue);
1299
1584
 
1300
- this.transcriptLines = buildOverlayTranscript(entries, this.theme);
1585
+ this.rebuildTranscriptLines();
1301
1586
  this.transcript.clear();
1302
1587
  for (const line of this.transcriptLines) {
1303
1588
  this.transcript.addChild(new Text(line, 1, 0));
@@ -1306,7 +1591,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1306
1591
  const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
1307
1592
  this.statusTextValue = status;
1308
1593
  this.statusText.setText(this.statusTextValue);
1309
- this.hintsTextValue = "Scroll wheel ↑↓ PgUp/PgDn · Enter · Alt+/ focus · Esc";
1594
+ this.hintsTextValue = `Scroll wheel ↑↓ PgUp/PgDn · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
1310
1595
  this.hintsText.setText(this.hintsTextValue);
1311
1596
  this.tui.requestRender();
1312
1597
  }
@@ -1320,9 +1605,16 @@ export default function (pi: ExtensionAPI) {
1320
1605
  let transcriptState = createEmptyTranscriptState();
1321
1606
  let overlayStatus: string | null = null;
1322
1607
  let overlayDraft = "";
1608
+ let overlayWidthMode: BtwOverlayWidthMode = "window";
1323
1609
  let overlayRuntime: OverlayRuntime | null = null;
1324
1610
  let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
1325
1611
  let activeBtwSession: BtwSessionRuntime | null = null;
1612
+ let btwLifecycleGeneration = 0;
1613
+ let btwSubmissionQueue = Promise.resolve();
1614
+
1615
+ function invalidateBtwLifecycle(): void {
1616
+ btwLifecycleGeneration += 1;
1617
+ }
1326
1618
 
1327
1619
  function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
1328
1620
  const activeCtx = ctx ?? lastUiContext;
@@ -1373,6 +1665,43 @@ export default function (pi: ExtensionAPI) {
1373
1665
  overlayRuntime?.refresh?.();
1374
1666
  }
1375
1667
 
1668
+ function getOverlayOptions(): OverlayOptions {
1669
+ const base: OverlayOptions = {
1670
+ minWidth: 72,
1671
+ maxHeight: "78%",
1672
+ anchor: "top-center",
1673
+ nonCapturing: true,
1674
+ };
1675
+ if (overlayWidthMode === "full") {
1676
+ // Edge-to-edge so a terminal Shift+drag selection captures only the
1677
+ // dialog's own text — nothing from the main screen sits beside it.
1678
+ return { ...base, width: "100%", margin: { top: 1 } };
1679
+ }
1680
+ // Framed "window" look: narrower, inset from the terminal edges.
1681
+ return { ...base, width: "78%", margin: { top: 1, left: 2, right: 2 } };
1682
+ }
1683
+
1684
+ async function toggleOverlayWidth(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
1685
+ overlayWidthMode = overlayWidthMode === "window" ? "full" : "window";
1686
+
1687
+ // overlayOptions is resolved once at showOverlay time, so a width change
1688
+ // requires tearing down and re-opening the overlay. The close path persists
1689
+ // the draft into overlayDraft, and ensureOverlay restores it on reopen.
1690
+ const wasFocused = overlayRuntime?.handle?.isFocused() ?? true;
1691
+ dismissOverlay();
1692
+ await ensureOverlay(ctx);
1693
+ if (!wasFocused) {
1694
+ overlayRuntime?.handle?.unfocus();
1695
+ overlayRuntime?.refresh?.();
1696
+ }
1697
+ setOverlayStatus(
1698
+ overlayWidthMode === "full"
1699
+ ? "Full-width mode. Shift+drag now selects only the dialog. Alt+w to restore the window."
1700
+ : "Window mode. Alt+w switches to full-width for clean copy selection.",
1701
+ ctx,
1702
+ );
1703
+ }
1704
+
1376
1705
  function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
1377
1706
  if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
1378
1707
  return;
@@ -1439,6 +1768,15 @@ export default function (pi: ExtensionAPI) {
1439
1768
  sessionRuntime.subscriptions.add(unsubscribe);
1440
1769
  }
1441
1770
 
1771
+ function requestBtwSessionAbort(sessionRuntime: BtwSessionRuntime): Promise<void> {
1772
+ sessionRuntime.abortPromise ??= Promise.resolve()
1773
+ .then(() => sessionRuntime.session.abort())
1774
+ .catch(() => {
1775
+ // Ignore abort errors during BTW cancellation/replacement/shutdown.
1776
+ });
1777
+ return sessionRuntime.abortPromise;
1778
+ }
1779
+
1442
1780
  async function disposeBtwSession(): Promise<void> {
1443
1781
  const current = activeBtwSession;
1444
1782
  activeBtwSession = null;
@@ -1447,28 +1785,45 @@ export default function (pi: ExtensionAPI) {
1447
1785
  }
1448
1786
 
1449
1787
  clearBtwSessionSubscriptions(current);
1450
-
1451
- try {
1452
- await current.session.abort();
1453
- } catch {
1454
- // Ignore abort errors during BTW session replacement/shutdown.
1455
- }
1456
-
1788
+ await requestBtwSessionAbort(current);
1457
1789
  current.session.dispose();
1458
1790
  }
1459
1791
 
1460
1792
  async function dismissOverlaySession(): Promise<void> {
1793
+ invalidateBtwLifecycle();
1461
1794
  dismissOverlay();
1462
1795
  await disposeBtwSession();
1463
1796
  }
1464
1797
 
1798
+ /**
1799
+ * Escape behaves differently depending on whether the BTW side session is
1800
+ * currently doing work:
1801
+ *
1802
+ * - streaming: the first Escape aborts the in-flight request but keeps the
1803
+ * overlay open (so the partial transcript stays readable and the thread
1804
+ * remains usable). A second Escape dismisses, even while cancellation settles.
1805
+ * - idle: Escape dismisses the overlay immediately (previous behavior).
1806
+ */
1807
+ async function dismissOrAbortOverlaySession(): Promise<void> {
1808
+ const sessionRuntime = activeBtwSession;
1809
+ if (sessionRuntime?.session.isStreaming && !sessionRuntime.abortPromise) {
1810
+ setOverlayStatus("⏹ Aborting. Press Esc again to dismiss the BTW overlay.");
1811
+ await requestBtwSessionAbort(sessionRuntime);
1812
+ if (activeBtwSession === sessionRuntime && overlayRuntime) {
1813
+ setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.");
1814
+ }
1815
+ return;
1816
+ }
1817
+ await dismissOverlaySession();
1818
+ }
1819
+
1465
1820
  async function resolveBtwModel(
1466
1821
  ctx: ExtensionCommandContext,
1467
1822
  notifyOnFallback = false,
1468
1823
  ): Promise<ResolvedBtwModel> {
1469
1824
  if (btwModelOverride) {
1470
1825
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride);
1471
- if (auth.ok && auth.apiKey) {
1826
+ if (hasUsableModelAuth(ctx, btwModelOverride, auth)) {
1472
1827
  return {
1473
1828
  model: btwModelOverride,
1474
1829
  source: "override",
@@ -1559,6 +1914,7 @@ export default function (pi: ExtensionAPI) {
1559
1914
  }
1560
1915
 
1561
1916
  async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise<void> {
1917
+ invalidateBtwLifecycle();
1562
1918
  btwModelOverride = nextModel;
1563
1919
  const details: BtwModelOverrideDetails = nextModel
1564
1920
  ? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api }
@@ -1577,6 +1933,7 @@ export default function (pi: ExtensionAPI) {
1577
1933
  ctx: ExtensionCommandContext,
1578
1934
  nextThinkingLevel: SessionThinkingLevel | null,
1579
1935
  ): Promise<void> {
1936
+ invalidateBtwLifecycle();
1580
1937
  btwThinkingOverride = nextThinkingLevel;
1581
1938
  const details: BtwThinkingOverrideDetails = nextThinkingLevel
1582
1939
  ? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel }
@@ -1591,32 +1948,38 @@ export default function (pi: ExtensionAPI) {
1591
1948
  notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info");
1592
1949
  }
1593
1950
 
1594
- async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime> {
1595
- const settings = await resolveBtwSettings(ctx, true);
1951
+ async function createBtwSubSession(
1952
+ ctx: ExtensionCommandContext,
1953
+ mode: BtwThreadMode,
1954
+ settings: ResolvedBtwSettings,
1955
+ ): Promise<BtwSessionRuntime> {
1596
1956
  if (!settings.model) {
1597
1957
  throw new Error(settings.fallbackReason || "No active model selected.");
1598
1958
  }
1599
1959
 
1600
- const { session } = await createAgentSession({
1960
+ const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, settings.model);
1961
+
1962
+ const sessionOptions: CreateAgentSessionOptions = {
1601
1963
  sessionManager: SessionManager.inMemory(),
1602
1964
  model: settings.model,
1603
- modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
1965
+ ...modelRuntimeOptions,
1604
1966
  thinkingLevel: settings.thinkingLevel,
1605
1967
  // Match pi's default coding-agent toolset (read/bash/edit/write).
1606
1968
  tools: ["read", "bash", "edit", "write"],
1607
1969
  resourceLoader: createBtwResourceLoader(ctx),
1608
- });
1970
+ };
1971
+ const { session } = await createAgentSession(sessionOptions);
1609
1972
 
1610
1973
  const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
1611
1974
  if (seedMessages.length > 0) {
1612
1975
  session.agent.state.messages = seedMessages as typeof session.state.messages;
1613
1976
  }
1614
1977
 
1615
- return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
1978
+ return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve() };
1616
1979
  }
1617
1980
 
1618
1981
  async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
1619
- const settings = await resolveBtwSettings(ctx);
1982
+ const settings = await resolveBtwSettings(ctx, true);
1620
1983
  if (!settings.model) {
1621
1984
  return null;
1622
1985
  }
@@ -1626,12 +1989,12 @@ export default function (pi: ExtensionAPI) {
1626
1989
  }
1627
1990
 
1628
1991
  await disposeBtwSession();
1629
- activeBtwSession = await createBtwSubSession(ctx, mode);
1992
+ activeBtwSession = await createBtwSubSession(ctx, mode, settings);
1630
1993
  return activeBtwSession;
1631
1994
  }
1632
1995
 
1633
1996
  async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
1634
- if (!ctx.hasUI) {
1997
+ if (!canRenderBtwOverlay(ctx)) {
1635
1998
  return;
1636
1999
  }
1637
2000
  lastUiContext = ctx;
@@ -1651,7 +2014,6 @@ export default function (pi: ExtensionAPI) {
1651
2014
  if (activeBtwSession) {
1652
2015
  clearBtwSessionSubscriptions(activeBtwSession);
1653
2016
  }
1654
- runtime.handle?.hide();
1655
2017
  if (overlayRuntime === runtime) {
1656
2018
  overlayRuntime = null;
1657
2019
  }
@@ -1675,16 +2037,20 @@ export default function (pi: ExtensionAPI) {
1675
2037
  () => transcriptState.entries,
1676
2038
  () => overlayStatus,
1677
2039
  () => pendingMode,
2040
+ () => overlayWidthMode,
1678
2041
  (value) => {
1679
2042
  void submitFromOverlay(ctx, value);
1680
2043
  },
1681
2044
  () => {
1682
- void dismissOverlaySession();
2045
+ void dismissOrAbortOverlaySession();
1683
2046
  },
1684
2047
  () => {
1685
2048
  overlayRuntime?.handle?.unfocus();
1686
2049
  overlayRuntime?.refresh?.();
1687
2050
  },
2051
+ () => {
2052
+ void toggleOverlayWidth(ctx);
2053
+ },
1688
2054
  );
1689
2055
 
1690
2056
  overlay.focused = runtime.handle?.isFocused() ?? true;
@@ -1698,7 +2064,6 @@ export default function (pi: ExtensionAPI) {
1698
2064
  };
1699
2065
  runtime.close = () => {
1700
2066
  overlayDraft = overlay.getDraft();
1701
- overlay.dispose();
1702
2067
  closeRuntime();
1703
2068
  };
1704
2069
 
@@ -1712,14 +2077,7 @@ export default function (pi: ExtensionAPI) {
1712
2077
  },
1713
2078
  {
1714
2079
  overlay: true,
1715
- overlayOptions: {
1716
- width: "78%",
1717
- minWidth: 72,
1718
- maxHeight: "78%",
1719
- anchor: "top-center",
1720
- margin: { top: 1, left: 2, right: 2 },
1721
- nonCapturing: true,
1722
- },
2080
+ overlayOptions: getOverlayOptions(),
1723
2081
  onHandle: (handle) => {
1724
2082
  runtime.handle = handle;
1725
2083
  handle.focus();
@@ -1743,6 +2101,10 @@ export default function (pi: ExtensionAPI) {
1743
2101
  if (name === "btw") {
1744
2102
  const { question, save } = parseBtwArgs(trimmedArgs);
1745
2103
  if (!question) {
2104
+ if (!canRenderBtwOverlay(ctx)) {
2105
+ notifyInlineQuestionRequired(ctx, "/btw");
2106
+ return true;
2107
+ }
1746
2108
  await ensureBtwSession(ctx, pendingMode);
1747
2109
  await ensureOverlay(ctx);
1748
2110
  return true;
@@ -1758,6 +2120,10 @@ export default function (pi: ExtensionAPI) {
1758
2120
 
1759
2121
  if (name === "btw:tangent") {
1760
2122
  const { question, save } = parseBtwArgs(trimmedArgs);
2123
+ if (!question && !canRenderBtwOverlay(ctx)) {
2124
+ notifyInlineQuestionRequired(ctx, "/btw:tangent");
2125
+ return true;
2126
+ }
1761
2127
  if (pendingMode !== "tangent") {
1762
2128
  await resetThread(ctx, true, "tangent");
1763
2129
  }
@@ -1773,8 +2139,13 @@ export default function (pi: ExtensionAPI) {
1773
2139
  }
1774
2140
 
1775
2141
  if (name === "btw:new") {
1776
- await resetThread(ctx, true, "contextual");
1777
2142
  const { question, save } = parseBtwArgs(trimmedArgs);
2143
+ if (!question && !canRenderBtwOverlay(ctx)) {
2144
+ notifyInlineQuestionRequired(ctx, "/btw:new");
2145
+ return true;
2146
+ }
2147
+
2148
+ await resetThread(ctx, true, "contextual");
1778
2149
  if (question) {
1779
2150
  await runBtw(ctx, question, save, "contextual");
1780
2151
  } else {
@@ -1840,6 +2211,7 @@ export default function (pi: ExtensionAPI) {
1840
2211
  }
1841
2212
 
1842
2213
  if (name === "btw:inject") {
2214
+ await btwSubmissionQueue;
1843
2215
  if (pendingThread.length === 0) {
1844
2216
  notify(ctx, "No BTW thread to inject.", "warning");
1845
2217
  return true;
@@ -1868,6 +2240,7 @@ export default function (pi: ExtensionAPI) {
1868
2240
  }
1869
2241
 
1870
2242
  if (name === "btw:summarize") {
2243
+ await btwSubmissionQueue;
1871
2244
  if (pendingThread.length === 0) {
1872
2245
  notify(ctx, "No BTW thread to summarize.", "warning");
1873
2246
  return true;
@@ -1943,6 +2316,7 @@ export default function (pi: ExtensionAPI) {
1943
2316
  persist = true,
1944
2317
  mode: BtwThreadMode = "contextual",
1945
2318
  ): Promise<void> {
2319
+ invalidateBtwLifecycle();
1946
2320
  await disposeBtwSession();
1947
2321
  pendingThread = [];
1948
2322
  pendingMode = mode;
@@ -1957,6 +2331,7 @@ export default function (pi: ExtensionAPI) {
1957
2331
  }
1958
2332
 
1959
2333
  async function restoreThread(ctx: ExtensionContext): Promise<void> {
2334
+ invalidateBtwLifecycle();
1960
2335
  await disposeBtwSession();
1961
2336
  pendingThread = [];
1962
2337
  pendingMode = "contextual";
@@ -2031,8 +2406,30 @@ export default function (pi: ExtensionAPI) {
2031
2406
  saveRequested: boolean,
2032
2407
  mode: BtwThreadMode,
2033
2408
  ): Promise<void> {
2409
+ const generation = btwLifecycleGeneration;
2410
+ const submission = btwSubmissionQueue.then(async () => {
2411
+ if (generation !== btwLifecycleGeneration) {
2412
+ return;
2413
+ }
2414
+ await executeBtw(ctx, question, saveRequested, mode, generation);
2415
+ });
2416
+ btwSubmissionQueue = submission.catch(() => {});
2417
+ await submission;
2418
+ }
2419
+
2420
+ async function executeBtw(
2421
+ ctx: ExtensionCommandContext,
2422
+ question: string,
2423
+ saveRequested: boolean,
2424
+ mode: BtwThreadMode,
2425
+ generation: number,
2426
+ ): Promise<void> {
2427
+ const isCurrentGeneration = () => generation === btwLifecycleGeneration;
2034
2428
  lastUiContext = ctx;
2035
2429
  const settings = await resolveBtwSettings(ctx);
2430
+ if (!isCurrentGeneration()) {
2431
+ return;
2432
+ }
2036
2433
  const model = settings.model;
2037
2434
  if (!model) {
2038
2435
  const message = settings.fallbackReason || "No active model selected.";
@@ -2042,7 +2439,10 @@ export default function (pi: ExtensionAPI) {
2042
2439
  }
2043
2440
 
2044
2441
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2045
- if (!auth.ok || !auth.apiKey) {
2442
+ if (!isCurrentGeneration()) {
2443
+ return;
2444
+ }
2445
+ if (!hasUsableModelAuth(ctx, model, auth)) {
2046
2446
  const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
2047
2447
  setOverlayStatus(message, ctx);
2048
2448
  notify(ctx, message, "error");
@@ -2051,6 +2451,12 @@ export default function (pi: ExtensionAPI) {
2051
2451
  }
2052
2452
 
2053
2453
  const sessionRuntime = await ensureBtwSession(ctx, mode);
2454
+ if (!isCurrentGeneration()) {
2455
+ if (sessionRuntime && activeBtwSession === sessionRuntime) {
2456
+ await disposeBtwSession();
2457
+ }
2458
+ return;
2459
+ }
2054
2460
  if (!sessionRuntime) {
2055
2461
  setOverlayStatus("No active model selected.", ctx);
2056
2462
  notify(ctx, "No active model selected.", "error");
@@ -2059,22 +2465,58 @@ export default function (pi: ExtensionAPI) {
2059
2465
 
2060
2466
  const session = sessionRuntime.session;
2061
2467
  const wasBusy = !ctx.isIdle();
2468
+ const overlayAvailable = canRenderBtwOverlay(ctx);
2062
2469
  pendingMode = mode;
2063
2470
  const thinkingLevel = settings.thinkingLevel;
2064
2471
 
2472
+ let releasePromptTurn!: () => void;
2473
+ const previousPromptTurns = sessionRuntime.promptQueue;
2474
+ const currentPromptTurn = new Promise<void>((resolve) => {
2475
+ releasePromptTurn = resolve;
2476
+ });
2477
+ sessionRuntime.promptQueue = previousPromptTurns.then(() => currentPromptTurn);
2478
+
2479
+ if (session.isStreaming || sessionRuntime.abortPromise) {
2480
+ setOverlayStatus("⏳ waiting for the current BTW turn to finish...", ctx);
2481
+ }
2482
+ await previousPromptTurns;
2483
+ if (activeBtwSession !== sessionRuntime) {
2484
+ releasePromptTurn();
2485
+ return;
2486
+ }
2487
+
2488
+ if (sessionRuntime.abortPromise) {
2489
+ setOverlayStatus("⏳ waiting for cancellation to finish...", ctx);
2490
+ await sessionRuntime.abortPromise;
2491
+ if (activeBtwSession !== sessionRuntime) {
2492
+ releasePromptTurn();
2493
+ return;
2494
+ }
2495
+ }
2496
+
2497
+ if (!isCurrentGeneration()) {
2498
+ releasePromptTurn();
2499
+ return;
2500
+ }
2501
+
2502
+ sessionRuntime.abortPromise = undefined;
2065
2503
  setOverlayStatus("⏳ streaming...", ctx);
2066
2504
  await ensureOverlay(ctx);
2067
2505
 
2068
2506
  try {
2069
2507
  await session.prompt(question, { source: "extension" });
2508
+ if (!isCurrentGeneration()) {
2509
+ return;
2510
+ }
2070
2511
 
2071
2512
  const response = getLastAssistantMessage(session);
2072
2513
  if (!response) {
2073
2514
  throw new Error("BTW request finished without a response.");
2074
2515
  }
2075
2516
  if (response.stopReason === "aborted") {
2076
- removeTranscriptTurn(transcriptState, transcriptState.lastTurnId ?? transcriptState.currentTurnId);
2077
- setOverlayStatus("Request aborted.", ctx);
2517
+ const abortedTurnId = transcriptState.currentTurnId ?? transcriptState.lastTurnId;
2518
+ finishTranscriptTurn(transcriptState, abortedTurnId, "aborted");
2519
+ setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.", ctx);
2078
2520
  return;
2079
2521
  }
2080
2522
  if (response.stopReason === "error") {
@@ -2102,8 +2544,15 @@ export default function (pi: ExtensionAPI) {
2102
2544
  pendingThread.push(details);
2103
2545
  pi.appendEntry(BTW_ENTRY_TYPE, details);
2104
2546
 
2105
- const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
2106
- if (saveState === "saved") {
2547
+ const saveState = saveVisibleBtwNote(pi, details, saveRequested || !overlayAvailable, wasBusy);
2548
+ if (!overlayAvailable) {
2549
+ const message =
2550
+ saveState === "queued"
2551
+ ? "BTW response queued to display after the current turn finishes."
2552
+ : "Displayed BTW response in the session.";
2553
+ notify(ctx, message, "info");
2554
+ setOverlayStatus(message, ctx);
2555
+ } else if (saveState === "saved") {
2107
2556
  notify(ctx, "Saved BTW note to the session.", "info");
2108
2557
  setOverlayStatus("Saved BTW note to the session.", ctx);
2109
2558
  } else if (saveState === "queued") {
@@ -2113,12 +2562,16 @@ export default function (pi: ExtensionAPI) {
2113
2562
  setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
2114
2563
  }
2115
2564
  } catch (error) {
2565
+ if (!isCurrentGeneration()) {
2566
+ return;
2567
+ }
2116
2568
  const errorMessage = error instanceof Error ? error.message : String(error);
2117
2569
  setTranscriptFailure(transcriptState, errorMessage);
2118
2570
  setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
2119
2571
  notify(ctx, errorMessage, "error");
2120
2572
  await disposeBtwSession();
2121
2573
  } finally {
2574
+ releasePromptTurn();
2122
2575
  syncUi(ctx);
2123
2576
  }
2124
2577
  }
@@ -2130,7 +2583,20 @@ export default function (pi: ExtensionAPI) {
2130
2583
  async function getBtwHandoffThread(
2131
2584
  ctx: ExtensionCommandContext,
2132
2585
  ): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> {
2586
+ const pendingSubmissions = btwSubmissionQueue;
2587
+ await pendingSubmissions;
2588
+
2133
2589
  const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode));
2590
+ if (sessionRuntime) {
2591
+ const pendingPromptTurns = sessionRuntime.promptQueue;
2592
+ const pendingAbort = sessionRuntime.abortPromise;
2593
+ await pendingPromptTurns;
2594
+ await pendingAbort;
2595
+ if (activeBtwSession !== sessionRuntime) {
2596
+ throw new Error("BTW session closed before handoff completed.");
2597
+ }
2598
+ }
2599
+
2134
2600
  const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
2135
2601
  const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
2136
2602
 
@@ -2149,18 +2615,21 @@ export default function (pi: ExtensionAPI) {
2149
2615
  }
2150
2616
 
2151
2617
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2152
- if (!auth.ok || !auth.apiKey) {
2618
+ if (!hasUsableModelAuth(ctx, model, auth)) {
2153
2619
  throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
2154
2620
  }
2155
2621
 
2156
- const { session } = await createAgentSession({
2622
+ const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, model);
2623
+
2624
+ const sessionOptions: CreateAgentSessionOptions = {
2157
2625
  sessionManager: SessionManager.inMemory(),
2158
2626
  model,
2159
- modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
2627
+ ...modelRuntimeOptions,
2160
2628
  thinkingLevel: "off",
2161
2629
  tools: [],
2162
2630
  resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
2163
- });
2631
+ };
2632
+ const { session } = await createAgentSession(sessionOptions);
2164
2633
 
2165
2634
  try {
2166
2635
  await session.prompt(formatThread(thread), { source: "extension" });
@@ -2197,29 +2666,46 @@ export default function (pi: ExtensionAPI) {
2197
2666
 
2198
2667
  pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
2199
2668
  const details = message.details as BtwDetails | undefined;
2200
- const content = typeof message.content === "string" ? message.content : "[non-text btw message]";
2201
- const lines = [theme.fg("accent", theme.bold("[BTW]")), content];
2669
+ const content = details
2670
+ ? buildBtwMessageContent(details.question, details.answer)
2671
+ : typeof message.content === "string"
2672
+ ? message.content
2673
+ : "[non-text btw message]";
2674
+
2675
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
2676
+ box.addChild(new Text(theme.fg("accent", theme.bold("[BTW]")), 0, 0));
2677
+ box.addChild(
2678
+ new Markdown(content, 0, 0, getMarkdownTheme(), {
2679
+ color: (text: string) => theme.fg("customMessageText", text),
2680
+ }),
2681
+ );
2202
2682
 
2203
2683
  if (expanded && details) {
2204
- lines.push(
2205
- theme.fg(
2206
- "dim",
2207
- `model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`,
2684
+ box.addChild(
2685
+ new Text(
2686
+ theme.fg(
2687
+ "dim",
2688
+ `model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`,
2689
+ ),
2690
+ 0,
2691
+ 0,
2208
2692
  ),
2209
2693
  );
2210
2694
 
2211
2695
  if (details.usage) {
2212
- lines.push(
2213
- theme.fg(
2214
- "dim",
2215
- `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`,
2696
+ box.addChild(
2697
+ new Text(
2698
+ theme.fg(
2699
+ "dim",
2700
+ `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`,
2701
+ ),
2702
+ 0,
2703
+ 0,
2216
2704
  ),
2217
2705
  );
2218
2706
  }
2219
2707
  }
2220
2708
 
2221
- const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
2222
- box.addChild(new Text(lines.join("\n"), 0, 0));
2223
2709
  return box;
2224
2710
  });
2225
2711
 
@@ -2238,6 +2724,7 @@ export default function (pi: ExtensionAPI) {
2238
2724
  });
2239
2725
 
2240
2726
  pi.on("session_shutdown", async () => {
2727
+ invalidateBtwLifecycle();
2241
2728
  await disposeBtwSession();
2242
2729
  dismissOverlay();
2243
2730
  });
@@ -2251,6 +2738,16 @@ export default function (pi: ExtensionAPI) {
2251
2738
  });
2252
2739
  }
2253
2740
 
2741
+ pi.registerShortcut(BTW_WIDTH_TOGGLE_SHORTCUT, {
2742
+ description: "Toggle the BTW overlay between window and full-width layouts.",
2743
+ handler: async () => {
2744
+ if (!overlayRuntime || !lastUiContext) {
2745
+ return;
2746
+ }
2747
+ await toggleOverlayWidth(lastUiContext);
2748
+ },
2749
+ });
2750
+
2254
2751
  pi.registerCommand("btw", {
2255
2752
  description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
2256
2753
  handler: async (args, ctx) => {
@@ -2307,3 +2804,4 @@ export default function (pi: ExtensionAPI) {
2307
2804
  },
2308
2805
  });
2309
2806
  }
2807
+