pi-btw 0.4.0 → 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,12 +1211,28 @@ 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);
1001
1228
  }
1002
1229
  }
1003
1230
 
1231
+ /** Fixed overlay rows outside the transcript viewport (must match render() structure). */
1232
+ const BTW_OVERLAY_CHROME_LINES = 9;
1233
+ /** Indent applied to transcript block bodies. */
1234
+ const BTW_BLOCK_INDENT = " ";
1235
+
1004
1236
  function getOverlayTitle(mode: BtwThreadMode): string {
1005
1237
  return mode === "tangent" ? "BTW tangent" : "BTW";
1006
1238
  }
@@ -1024,14 +1256,19 @@ class BtwOverlayComponent extends Container implements Focusable {
1024
1256
  private readonly readTranscriptEntries: () => BtwTranscript;
1025
1257
  private readonly getStatus: () => string | null;
1026
1258
  private readonly getMode: () => BtwThreadMode;
1259
+ private readonly getWidthMode: () => BtwOverlayWidthMode;
1027
1260
  private readonly onSubmitCallback: (value: string) => void;
1028
1261
  private readonly onDismissCallback: () => void;
1029
1262
  private readonly onUnfocusCallback: () => void;
1263
+ private readonly onToggleWidthCallback: () => void;
1030
1264
  private readonly tui: TUI;
1031
1265
  private readonly theme: ExtensionContext["ui"]["theme"];
1266
+ private readonly markdownTheme: MarkdownTheme;
1267
+ private readonly managesMouseReporting: boolean;
1032
1268
  private transcriptLines: string[] = [];
1033
1269
  private transcriptScrollOffset = 0;
1034
1270
  private transcriptViewportHeight = 8;
1271
+ private contentWidth = 66;
1035
1272
  private followTranscript = true;
1036
1273
  private _focused = false;
1037
1274
  private modeTextValue = "";
@@ -1055,19 +1292,27 @@ class BtwOverlayComponent extends Container implements Focusable {
1055
1292
  readTranscriptEntries: () => BtwTranscript,
1056
1293
  getStatus: () => string | null,
1057
1294
  getMode: () => BtwThreadMode,
1295
+ getWidthMode: () => BtwOverlayWidthMode,
1058
1296
  onSubmit: (value: string) => void,
1059
1297
  onDismiss: () => void,
1060
1298
  onUnfocus: () => void,
1299
+ onToggleWidth: () => void,
1061
1300
  ) {
1062
1301
  super();
1063
1302
  this.tui = tui;
1064
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";
1065
1308
  this.readTranscriptEntries = readTranscriptEntries;
1066
1309
  this.getStatus = getStatus;
1067
1310
  this.getMode = getMode;
1311
+ this.getWidthMode = getWidthMode;
1068
1312
  this.onSubmitCallback = onSubmit;
1069
1313
  this.onDismissCallback = onDismiss;
1070
1314
  this.onUnfocusCallback = onUnfocus;
1315
+ this.onToggleWidthCallback = onToggleWidth;
1071
1316
 
1072
1317
  this.modeText = new Text("", 1, 0);
1073
1318
  this.summaryText = new Text("", 1, 0);
@@ -1085,6 +1330,10 @@ class BtwOverlayComponent extends Container implements Focusable {
1085
1330
 
1086
1331
  this.hintsText = new Text("", 1, 0);
1087
1332
 
1333
+ if (this.managesMouseReporting) {
1334
+ this.tui.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
1335
+ }
1336
+
1088
1337
  const originalHandleInput = this.input.handleInput.bind(this.input);
1089
1338
  this.input.handleInput = (data: string) => {
1090
1339
  if (keybindings.matches(data, "app.clear")) {
@@ -1108,20 +1357,36 @@ class BtwOverlayComponent extends Container implements Focusable {
1108
1357
  this.refresh();
1109
1358
  }
1110
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
+
1111
1367
  private frameLine(content: string, innerWidth: number): string {
1112
1368
  const truncated = truncateToWidth(content, innerWidth, "");
1113
1369
  const padding = Math.max(0, innerWidth - visibleWidth(truncated));
1114
- return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`;
1370
+ if (this.borderless) {
1371
+ return `${truncated}${" ".repeat(padding)}`;
1372
+ }
1373
+ return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
1115
1374
  }
1116
1375
 
1117
1376
  private ruleLine(innerWidth: number): string {
1118
- return this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
1377
+ if (this.borderless) {
1378
+ return this.theme.fg("border", "─".repeat(innerWidth));
1379
+ }
1380
+ return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
1119
1381
  }
1120
1382
 
1121
1383
  private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
1384
+ if (this.borderless) {
1385
+ return this.theme.fg("border", "─".repeat(innerWidth));
1386
+ }
1122
1387
  const left = edge === "top" ? "┌" : "└";
1123
1388
  const right = edge === "top" ? "┐" : "┘";
1124
- return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
1389
+ return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
1125
1390
  }
1126
1391
 
1127
1392
  private wrapTranscript(innerWidth: number): string[] {
@@ -1141,22 +1406,60 @@ class BtwOverlayComponent extends Container implements Focusable {
1141
1406
  return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
1142
1407
  }
1143
1408
 
1409
+ private scrollTranscript(delta: number): void {
1410
+ if (delta < 0) {
1411
+ this.followTranscript = false;
1412
+ }
1413
+ this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset + delta);
1414
+ this.tui.requestRender();
1415
+ }
1416
+
1417
+ dispose(): void {
1418
+ if (this.managesMouseReporting) {
1419
+ this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
1420
+ }
1421
+ }
1422
+
1423
+ private getMouseScrollDelta(data: string): number | null {
1424
+ const match = data.match(/^\x1b\[<(\d+);\d+;\d+[Mm]$/);
1425
+ if (!match) {
1426
+ return null;
1427
+ }
1428
+
1429
+ const button = Number(match[1]);
1430
+ if ((button & 64) !== 64) {
1431
+ return null;
1432
+ }
1433
+
1434
+ return (button & 1) === 0 ? -3 : 3;
1435
+ }
1436
+
1144
1437
  handleInput(data: string): void {
1145
1438
  if (matchesBtwFocusShortcut(data)) {
1146
1439
  this.onUnfocusCallback();
1147
1440
  return;
1148
1441
  }
1149
1442
 
1150
- if (matchesKey(data, Key.pageUp)) {
1151
- this.followTranscript = false;
1152
- this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset - Math.max(1, this.transcriptViewportHeight - 1));
1153
- this.tui.requestRender();
1443
+ if (matchesBtwWidthToggle(data)) {
1444
+ this.onToggleWidthCallback();
1445
+ return;
1446
+ }
1447
+
1448
+ const mouseScrollDelta = this.getMouseScrollDelta(data);
1449
+ if (mouseScrollDelta !== null) {
1450
+ this.scrollTranscript(mouseScrollDelta);
1154
1451
  return;
1155
1452
  }
1156
1453
 
1157
- if (matchesKey(data, Key.pageDown)) {
1158
- this.transcriptScrollOffset += Math.max(1, this.transcriptViewportHeight - 1);
1159
- this.tui.requestRender();
1454
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.up)) {
1455
+ const step = matchesKey(data, Key.pageUp) ? Math.max(1, this.transcriptViewportHeight - 1) : 1;
1456
+ this.scrollTranscript(-step);
1457
+ return;
1458
+ }
1459
+
1460
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.down)) {
1461
+ const step = matchesKey(data, Key.pageDown) ? Math.max(1, this.transcriptViewportHeight - 1) : 1;
1462
+ this.scrollTranscript(step);
1160
1463
  return;
1161
1464
  }
1162
1465
 
@@ -1164,7 +1467,8 @@ class BtwOverlayComponent extends Container implements Focusable {
1164
1467
  }
1165
1468
 
1166
1469
  private inputFrameLine(dialogWidth: number): string {
1167
- const targetWidth = Math.max(1, dialogWidth - 2);
1470
+ const borderColumns = this.borderless ? 0 : 2;
1471
+ const targetWidth = Math.max(1, dialogWidth - borderColumns);
1168
1472
  const previousFocused = this.input.focused;
1169
1473
  // Input.render() emits CURSOR_MARKER when focused. In overlay mode that APC marker
1170
1474
  // can skew width/composition on this one row before the TUI strips it, producing a
@@ -1172,19 +1476,34 @@ class BtwOverlayComponent extends Container implements Focusable {
1172
1476
  // the row stays geometrically stable while the overlay still owns keyboard input.
1173
1477
  this.input.focused = false;
1174
1478
  try {
1175
- const inputLine = this.input.render(targetWidth)[0] ?? "";
1176
- return `${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "")}`;
1479
+ const renderedInputLine = this.input.render(targetWidth)[0] ?? "";
1480
+ const inputLine = truncateToWidth(renderedInputLine, targetWidth, "");
1481
+ const padding = Math.max(0, targetWidth - visibleWidth(inputLine));
1482
+ if (this.borderless) {
1483
+ return `${inputLine}${" ".repeat(padding)}`;
1484
+ }
1485
+ return `${this.theme.fg("border", "│")}${inputLine}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
1177
1486
  } finally {
1178
1487
  this.input.focused = previousFocused;
1179
1488
  }
1180
1489
  }
1181
1490
 
1491
+ private fitRenderedLine(line: string, width: number): string {
1492
+ return visibleWidth(line) > width ? truncateToWidth(line, width, "") : line;
1493
+ }
1494
+
1182
1495
  override render(width: number): string[] {
1183
1496
  const dialogWidth = Math.max(24, width);
1184
- 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
+ }
1185
1504
  const transcriptLines = this.wrapTranscript(innerWidth);
1186
1505
  const dialogHeight = this.getDialogHeight();
1187
- const chromeHeight = 8;
1506
+ const chromeHeight = BTW_OVERLAY_CHROME_LINES;
1188
1507
  const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
1189
1508
  this.transcriptViewportHeight = transcriptHeight;
1190
1509
 
@@ -1229,7 +1548,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1229
1548
  lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth));
1230
1549
  lines.push(this.borderLine(innerWidth, "bottom"));
1231
1550
 
1232
- return lines;
1551
+ return lines.map((line) => this.fitRenderedLine(line, width));
1233
1552
  }
1234
1553
 
1235
1554
  setDraft(value: string): void {
@@ -1245,6 +1564,15 @@ class BtwOverlayComponent extends Container implements Focusable {
1245
1564
  return this.readTranscriptEntries().map((entry) => ({ ...entry }));
1246
1565
  }
1247
1566
 
1567
+ private rebuildTranscriptLines(): void {
1568
+ this.transcriptLines = buildOverlayTranscript(
1569
+ this.readTranscriptEntries(),
1570
+ this.theme,
1571
+ this.markdownTheme,
1572
+ this.contentWidth,
1573
+ );
1574
+ }
1575
+
1248
1576
  refresh(): void {
1249
1577
  this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
1250
1578
  this.modeText.setText(this.modeTextValue);
@@ -1254,7 +1582,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1254
1582
  this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`;
1255
1583
  this.summaryText.setText(this.summaryTextValue);
1256
1584
 
1257
- this.transcriptLines = buildOverlayTranscript(entries, this.theme);
1585
+ this.rebuildTranscriptLines();
1258
1586
  this.transcript.clear();
1259
1587
  for (const line of this.transcriptLines) {
1260
1588
  this.transcript.addChild(new Text(line, 1, 0));
@@ -1263,7 +1591,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1263
1591
  const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
1264
1592
  this.statusTextValue = status;
1265
1593
  this.statusText.setText(this.statusTextValue);
1266
- this.hintsTextValue = "Enter submit · Alt+/ toggle focus · Escape dismiss · PgUp/PgDn scroll";
1594
+ this.hintsTextValue = `Scroll wheel ↑↓ PgUp/PgDn · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
1267
1595
  this.hintsText.setText(this.hintsTextValue);
1268
1596
  this.tui.requestRender();
1269
1597
  }
@@ -1277,9 +1605,16 @@ export default function (pi: ExtensionAPI) {
1277
1605
  let transcriptState = createEmptyTranscriptState();
1278
1606
  let overlayStatus: string | null = null;
1279
1607
  let overlayDraft = "";
1608
+ let overlayWidthMode: BtwOverlayWidthMode = "window";
1280
1609
  let overlayRuntime: OverlayRuntime | null = null;
1281
1610
  let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
1282
1611
  let activeBtwSession: BtwSessionRuntime | null = null;
1612
+ let btwLifecycleGeneration = 0;
1613
+ let btwSubmissionQueue = Promise.resolve();
1614
+
1615
+ function invalidateBtwLifecycle(): void {
1616
+ btwLifecycleGeneration += 1;
1617
+ }
1283
1618
 
1284
1619
  function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
1285
1620
  const activeCtx = ctx ?? lastUiContext;
@@ -1330,6 +1665,43 @@ export default function (pi: ExtensionAPI) {
1330
1665
  overlayRuntime?.refresh?.();
1331
1666
  }
1332
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
+
1333
1705
  function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
1334
1706
  if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
1335
1707
  return;
@@ -1396,6 +1768,15 @@ export default function (pi: ExtensionAPI) {
1396
1768
  sessionRuntime.subscriptions.add(unsubscribe);
1397
1769
  }
1398
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
+
1399
1780
  async function disposeBtwSession(): Promise<void> {
1400
1781
  const current = activeBtwSession;
1401
1782
  activeBtwSession = null;
@@ -1404,28 +1785,45 @@ export default function (pi: ExtensionAPI) {
1404
1785
  }
1405
1786
 
1406
1787
  clearBtwSessionSubscriptions(current);
1407
-
1408
- try {
1409
- await current.session.abort();
1410
- } catch {
1411
- // Ignore abort errors during BTW session replacement/shutdown.
1412
- }
1413
-
1788
+ await requestBtwSessionAbort(current);
1414
1789
  current.session.dispose();
1415
1790
  }
1416
1791
 
1417
1792
  async function dismissOverlaySession(): Promise<void> {
1793
+ invalidateBtwLifecycle();
1418
1794
  dismissOverlay();
1419
1795
  await disposeBtwSession();
1420
1796
  }
1421
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
+
1422
1820
  async function resolveBtwModel(
1423
1821
  ctx: ExtensionCommandContext,
1424
1822
  notifyOnFallback = false,
1425
1823
  ): Promise<ResolvedBtwModel> {
1426
1824
  if (btwModelOverride) {
1427
1825
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride);
1428
- if (auth.ok && auth.apiKey) {
1826
+ if (hasUsableModelAuth(ctx, btwModelOverride, auth)) {
1429
1827
  return {
1430
1828
  model: btwModelOverride,
1431
1829
  source: "override",
@@ -1516,6 +1914,7 @@ export default function (pi: ExtensionAPI) {
1516
1914
  }
1517
1915
 
1518
1916
  async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise<void> {
1917
+ invalidateBtwLifecycle();
1519
1918
  btwModelOverride = nextModel;
1520
1919
  const details: BtwModelOverrideDetails = nextModel
1521
1920
  ? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api }
@@ -1534,6 +1933,7 @@ export default function (pi: ExtensionAPI) {
1534
1933
  ctx: ExtensionCommandContext,
1535
1934
  nextThinkingLevel: SessionThinkingLevel | null,
1536
1935
  ): Promise<void> {
1936
+ invalidateBtwLifecycle();
1537
1937
  btwThinkingOverride = nextThinkingLevel;
1538
1938
  const details: BtwThinkingOverrideDetails = nextThinkingLevel
1539
1939
  ? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel }
@@ -1548,32 +1948,38 @@ export default function (pi: ExtensionAPI) {
1548
1948
  notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info");
1549
1949
  }
1550
1950
 
1551
- async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime> {
1552
- const settings = await resolveBtwSettings(ctx, true);
1951
+ async function createBtwSubSession(
1952
+ ctx: ExtensionCommandContext,
1953
+ mode: BtwThreadMode,
1954
+ settings: ResolvedBtwSettings,
1955
+ ): Promise<BtwSessionRuntime> {
1553
1956
  if (!settings.model) {
1554
1957
  throw new Error(settings.fallbackReason || "No active model selected.");
1555
1958
  }
1556
1959
 
1557
- const { session } = await createAgentSession({
1960
+ const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, settings.model);
1961
+
1962
+ const sessionOptions: CreateAgentSessionOptions = {
1558
1963
  sessionManager: SessionManager.inMemory(),
1559
1964
  model: settings.model,
1560
- modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
1965
+ ...modelRuntimeOptions,
1561
1966
  thinkingLevel: settings.thinkingLevel,
1562
1967
  // Match pi's default coding-agent toolset (read/bash/edit/write).
1563
1968
  tools: ["read", "bash", "edit", "write"],
1564
1969
  resourceLoader: createBtwResourceLoader(ctx),
1565
- });
1970
+ };
1971
+ const { session } = await createAgentSession(sessionOptions);
1566
1972
 
1567
1973
  const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
1568
1974
  if (seedMessages.length > 0) {
1569
1975
  session.agent.state.messages = seedMessages as typeof session.state.messages;
1570
1976
  }
1571
1977
 
1572
- return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
1978
+ return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve() };
1573
1979
  }
1574
1980
 
1575
1981
  async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
1576
- const settings = await resolveBtwSettings(ctx);
1982
+ const settings = await resolveBtwSettings(ctx, true);
1577
1983
  if (!settings.model) {
1578
1984
  return null;
1579
1985
  }
@@ -1583,12 +1989,12 @@ export default function (pi: ExtensionAPI) {
1583
1989
  }
1584
1990
 
1585
1991
  await disposeBtwSession();
1586
- activeBtwSession = await createBtwSubSession(ctx, mode);
1992
+ activeBtwSession = await createBtwSubSession(ctx, mode, settings);
1587
1993
  return activeBtwSession;
1588
1994
  }
1589
1995
 
1590
1996
  async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
1591
- if (!ctx.hasUI) {
1997
+ if (!canRenderBtwOverlay(ctx)) {
1592
1998
  return;
1593
1999
  }
1594
2000
  lastUiContext = ctx;
@@ -1608,7 +2014,6 @@ export default function (pi: ExtensionAPI) {
1608
2014
  if (activeBtwSession) {
1609
2015
  clearBtwSessionSubscriptions(activeBtwSession);
1610
2016
  }
1611
- runtime.handle?.hide();
1612
2017
  if (overlayRuntime === runtime) {
1613
2018
  overlayRuntime = null;
1614
2019
  }
@@ -1632,16 +2037,20 @@ export default function (pi: ExtensionAPI) {
1632
2037
  () => transcriptState.entries,
1633
2038
  () => overlayStatus,
1634
2039
  () => pendingMode,
2040
+ () => overlayWidthMode,
1635
2041
  (value) => {
1636
2042
  void submitFromOverlay(ctx, value);
1637
2043
  },
1638
2044
  () => {
1639
- void dismissOverlaySession();
2045
+ void dismissOrAbortOverlaySession();
1640
2046
  },
1641
2047
  () => {
1642
2048
  overlayRuntime?.handle?.unfocus();
1643
2049
  overlayRuntime?.refresh?.();
1644
2050
  },
2051
+ () => {
2052
+ void toggleOverlayWidth(ctx);
2053
+ },
1645
2054
  );
1646
2055
 
1647
2056
  overlay.focused = runtime.handle?.isFocused() ?? true;
@@ -1668,14 +2077,7 @@ export default function (pi: ExtensionAPI) {
1668
2077
  },
1669
2078
  {
1670
2079
  overlay: true,
1671
- overlayOptions: {
1672
- width: "78%",
1673
- minWidth: 72,
1674
- maxHeight: "78%",
1675
- anchor: "top-center",
1676
- margin: { top: 1, left: 2, right: 2 },
1677
- nonCapturing: true,
1678
- },
2080
+ overlayOptions: getOverlayOptions(),
1679
2081
  onHandle: (handle) => {
1680
2082
  runtime.handle = handle;
1681
2083
  handle.focus();
@@ -1699,6 +2101,10 @@ export default function (pi: ExtensionAPI) {
1699
2101
  if (name === "btw") {
1700
2102
  const { question, save } = parseBtwArgs(trimmedArgs);
1701
2103
  if (!question) {
2104
+ if (!canRenderBtwOverlay(ctx)) {
2105
+ notifyInlineQuestionRequired(ctx, "/btw");
2106
+ return true;
2107
+ }
1702
2108
  await ensureBtwSession(ctx, pendingMode);
1703
2109
  await ensureOverlay(ctx);
1704
2110
  return true;
@@ -1714,6 +2120,10 @@ export default function (pi: ExtensionAPI) {
1714
2120
 
1715
2121
  if (name === "btw:tangent") {
1716
2122
  const { question, save } = parseBtwArgs(trimmedArgs);
2123
+ if (!question && !canRenderBtwOverlay(ctx)) {
2124
+ notifyInlineQuestionRequired(ctx, "/btw:tangent");
2125
+ return true;
2126
+ }
1717
2127
  if (pendingMode !== "tangent") {
1718
2128
  await resetThread(ctx, true, "tangent");
1719
2129
  }
@@ -1729,8 +2139,13 @@ export default function (pi: ExtensionAPI) {
1729
2139
  }
1730
2140
 
1731
2141
  if (name === "btw:new") {
1732
- await resetThread(ctx, true, "contextual");
1733
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");
1734
2149
  if (question) {
1735
2150
  await runBtw(ctx, question, save, "contextual");
1736
2151
  } else {
@@ -1796,6 +2211,7 @@ export default function (pi: ExtensionAPI) {
1796
2211
  }
1797
2212
 
1798
2213
  if (name === "btw:inject") {
2214
+ await btwSubmissionQueue;
1799
2215
  if (pendingThread.length === 0) {
1800
2216
  notify(ctx, "No BTW thread to inject.", "warning");
1801
2217
  return true;
@@ -1824,6 +2240,7 @@ export default function (pi: ExtensionAPI) {
1824
2240
  }
1825
2241
 
1826
2242
  if (name === "btw:summarize") {
2243
+ await btwSubmissionQueue;
1827
2244
  if (pendingThread.length === 0) {
1828
2245
  notify(ctx, "No BTW thread to summarize.", "warning");
1829
2246
  return true;
@@ -1899,6 +2316,7 @@ export default function (pi: ExtensionAPI) {
1899
2316
  persist = true,
1900
2317
  mode: BtwThreadMode = "contextual",
1901
2318
  ): Promise<void> {
2319
+ invalidateBtwLifecycle();
1902
2320
  await disposeBtwSession();
1903
2321
  pendingThread = [];
1904
2322
  pendingMode = mode;
@@ -1913,6 +2331,7 @@ export default function (pi: ExtensionAPI) {
1913
2331
  }
1914
2332
 
1915
2333
  async function restoreThread(ctx: ExtensionContext): Promise<void> {
2334
+ invalidateBtwLifecycle();
1916
2335
  await disposeBtwSession();
1917
2336
  pendingThread = [];
1918
2337
  pendingMode = "contextual";
@@ -1987,8 +2406,30 @@ export default function (pi: ExtensionAPI) {
1987
2406
  saveRequested: boolean,
1988
2407
  mode: BtwThreadMode,
1989
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;
1990
2428
  lastUiContext = ctx;
1991
2429
  const settings = await resolveBtwSettings(ctx);
2430
+ if (!isCurrentGeneration()) {
2431
+ return;
2432
+ }
1992
2433
  const model = settings.model;
1993
2434
  if (!model) {
1994
2435
  const message = settings.fallbackReason || "No active model selected.";
@@ -1998,7 +2439,10 @@ export default function (pi: ExtensionAPI) {
1998
2439
  }
1999
2440
 
2000
2441
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2001
- if (!auth.ok || !auth.apiKey) {
2442
+ if (!isCurrentGeneration()) {
2443
+ return;
2444
+ }
2445
+ if (!hasUsableModelAuth(ctx, model, auth)) {
2002
2446
  const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
2003
2447
  setOverlayStatus(message, ctx);
2004
2448
  notify(ctx, message, "error");
@@ -2007,6 +2451,12 @@ export default function (pi: ExtensionAPI) {
2007
2451
  }
2008
2452
 
2009
2453
  const sessionRuntime = await ensureBtwSession(ctx, mode);
2454
+ if (!isCurrentGeneration()) {
2455
+ if (sessionRuntime && activeBtwSession === sessionRuntime) {
2456
+ await disposeBtwSession();
2457
+ }
2458
+ return;
2459
+ }
2010
2460
  if (!sessionRuntime) {
2011
2461
  setOverlayStatus("No active model selected.", ctx);
2012
2462
  notify(ctx, "No active model selected.", "error");
@@ -2015,22 +2465,58 @@ export default function (pi: ExtensionAPI) {
2015
2465
 
2016
2466
  const session = sessionRuntime.session;
2017
2467
  const wasBusy = !ctx.isIdle();
2468
+ const overlayAvailable = canRenderBtwOverlay(ctx);
2018
2469
  pendingMode = mode;
2019
2470
  const thinkingLevel = settings.thinkingLevel;
2020
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;
2021
2503
  setOverlayStatus("⏳ streaming...", ctx);
2022
2504
  await ensureOverlay(ctx);
2023
2505
 
2024
2506
  try {
2025
2507
  await session.prompt(question, { source: "extension" });
2508
+ if (!isCurrentGeneration()) {
2509
+ return;
2510
+ }
2026
2511
 
2027
2512
  const response = getLastAssistantMessage(session);
2028
2513
  if (!response) {
2029
2514
  throw new Error("BTW request finished without a response.");
2030
2515
  }
2031
2516
  if (response.stopReason === "aborted") {
2032
- removeTranscriptTurn(transcriptState, transcriptState.lastTurnId ?? transcriptState.currentTurnId);
2033
- 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);
2034
2520
  return;
2035
2521
  }
2036
2522
  if (response.stopReason === "error") {
@@ -2058,8 +2544,15 @@ export default function (pi: ExtensionAPI) {
2058
2544
  pendingThread.push(details);
2059
2545
  pi.appendEntry(BTW_ENTRY_TYPE, details);
2060
2546
 
2061
- const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
2062
- 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") {
2063
2556
  notify(ctx, "Saved BTW note to the session.", "info");
2064
2557
  setOverlayStatus("Saved BTW note to the session.", ctx);
2065
2558
  } else if (saveState === "queued") {
@@ -2069,12 +2562,16 @@ export default function (pi: ExtensionAPI) {
2069
2562
  setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
2070
2563
  }
2071
2564
  } catch (error) {
2565
+ if (!isCurrentGeneration()) {
2566
+ return;
2567
+ }
2072
2568
  const errorMessage = error instanceof Error ? error.message : String(error);
2073
2569
  setTranscriptFailure(transcriptState, errorMessage);
2074
2570
  setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
2075
2571
  notify(ctx, errorMessage, "error");
2076
2572
  await disposeBtwSession();
2077
2573
  } finally {
2574
+ releasePromptTurn();
2078
2575
  syncUi(ctx);
2079
2576
  }
2080
2577
  }
@@ -2086,7 +2583,20 @@ export default function (pi: ExtensionAPI) {
2086
2583
  async function getBtwHandoffThread(
2087
2584
  ctx: ExtensionCommandContext,
2088
2585
  ): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> {
2586
+ const pendingSubmissions = btwSubmissionQueue;
2587
+ await pendingSubmissions;
2588
+
2089
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
+
2090
2600
  const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
2091
2601
  const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
2092
2602
 
@@ -2105,18 +2615,21 @@ export default function (pi: ExtensionAPI) {
2105
2615
  }
2106
2616
 
2107
2617
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2108
- if (!auth.ok || !auth.apiKey) {
2618
+ if (!hasUsableModelAuth(ctx, model, auth)) {
2109
2619
  throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
2110
2620
  }
2111
2621
 
2112
- const { session } = await createAgentSession({
2622
+ const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, model);
2623
+
2624
+ const sessionOptions: CreateAgentSessionOptions = {
2113
2625
  sessionManager: SessionManager.inMemory(),
2114
2626
  model,
2115
- modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
2627
+ ...modelRuntimeOptions,
2116
2628
  thinkingLevel: "off",
2117
2629
  tools: [],
2118
2630
  resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
2119
- });
2631
+ };
2632
+ const { session } = await createAgentSession(sessionOptions);
2120
2633
 
2121
2634
  try {
2122
2635
  await session.prompt(formatThread(thread), { source: "extension" });
@@ -2153,29 +2666,46 @@ export default function (pi: ExtensionAPI) {
2153
2666
 
2154
2667
  pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
2155
2668
  const details = message.details as BtwDetails | undefined;
2156
- const content = typeof message.content === "string" ? message.content : "[non-text btw message]";
2157
- 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
+ );
2158
2682
 
2159
2683
  if (expanded && details) {
2160
- lines.push(
2161
- theme.fg(
2162
- "dim",
2163
- `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,
2164
2692
  ),
2165
2693
  );
2166
2694
 
2167
2695
  if (details.usage) {
2168
- lines.push(
2169
- theme.fg(
2170
- "dim",
2171
- `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,
2172
2704
  ),
2173
2705
  );
2174
2706
  }
2175
2707
  }
2176
2708
 
2177
- const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
2178
- box.addChild(new Text(lines.join("\n"), 0, 0));
2179
2709
  return box;
2180
2710
  });
2181
2711
 
@@ -2194,6 +2724,7 @@ export default function (pi: ExtensionAPI) {
2194
2724
  });
2195
2725
 
2196
2726
  pi.on("session_shutdown", async () => {
2727
+ invalidateBtwLifecycle();
2197
2728
  await disposeBtwSession();
2198
2729
  dismissOverlay();
2199
2730
  });
@@ -2207,6 +2738,16 @@ export default function (pi: ExtensionAPI) {
2207
2738
  });
2208
2739
  }
2209
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
+
2210
2751
  pi.registerCommand("btw", {
2211
2752
  description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
2212
2753
  handler: async (args, ctx) => {
@@ -2263,3 +2804,4 @@ export default function (pi: ExtensionAPI) {
2263
2804
  },
2264
2805
  });
2265
2806
  }
2807
+