pi-btw 0.4.1 → 0.6.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.",
@@ -53,7 +176,7 @@ const BTW_CONTINUE_THREAD_USER_TEXT = "[The following is a separate side convers
53
176
  const BTW_CONTINUE_THREAD_ASSISTANT_TEXT = "Understood, continuing our side conversation.";
54
177
 
55
178
  type SessionThinkingLevel = "off" | AiThinkingLevel;
56
- type BtwThreadMode = "contextual" | "tangent";
179
+ type BtwThreadMode = "contextual" | "tangent" | "readonly";
57
180
  type SessionModel = NonNullable<ExtensionCommandContext["model"]>;
58
181
  /**
59
182
  * Loose model reference parsed from `/btw:model <provider> <id> <api>` and persisted to
@@ -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 {
@@ -260,6 +443,17 @@ function formatModelRef(model: Pick<SessionModel, "provider" | "id" | "api">): s
260
443
  return `${model.provider}/${model.id} (${model.api})`;
261
444
  }
262
445
 
446
+ /**
447
+ * Tool surfaces keyed by BTW mode. Read-only mode exposes only pi's built-in
448
+ * read-only tools so the child session cannot mutate the workspace; every other
449
+ * mode matches pi's default coding-agent toolset (read/bash/edit/write).
450
+ */
451
+ const BTW_TOOLS_BY_MODE: Record<BtwThreadMode, readonly string[]> = {
452
+ contextual: ["read", "bash", "edit", "write"],
453
+ tangent: ["read", "bash", "edit", "write"],
454
+ readonly: ["read", "grep", "find", "ls"],
455
+ };
456
+
263
457
  function buildBtwSeedState(
264
458
  ctx: ExtensionCommandContext,
265
459
  thread: BtwDetails[],
@@ -268,7 +462,7 @@ function buildBtwSeedState(
268
462
  ): { messages: Message[]; sideThreadStartIndex: number } {
269
463
  const messages: Message[] = [];
270
464
 
271
- if (mode === "contextual") {
465
+ if (mode === "contextual" || mode === "readonly") {
272
466
  try {
273
467
  messages.push(
274
468
  ...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter(
@@ -415,17 +609,29 @@ function ensureTranscriptTurn(state: BtwTranscriptState): number {
415
609
  return turnId;
416
610
  }
417
611
 
418
- function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null): void {
612
+ function finishTranscriptTurn(
613
+ state: BtwTranscriptState,
614
+ turnId?: number | null,
615
+ outcome: BtwTurnOutcome = "completed",
616
+ ): void {
419
617
  const resolvedTurnId = turnId ?? state.currentTurnId;
420
618
  if (resolvedTurnId === null || resolvedTurnId === undefined) {
421
619
  return;
422
620
  }
423
621
 
424
- const hasEndBoundary = state.entries.some(
425
- (entry) => entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
622
+ const endBoundary = state.entries.find(
623
+ (entry): entry is Extract<BtwTranscriptEntry, { type: "turn-boundary" }> =>
624
+ entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
426
625
  );
427
- if (!hasEndBoundary) {
428
- appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
626
+ if (endBoundary) {
627
+ endBoundary.outcome = outcome;
628
+ } else {
629
+ appendTranscriptEntry(state, {
630
+ type: "turn-boundary",
631
+ turnId: resolvedTurnId,
632
+ phase: "end",
633
+ outcome,
634
+ } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
429
635
  }
430
636
 
431
637
  for (const entry of state.entries) {
@@ -444,26 +650,6 @@ function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null)
444
650
  }
445
651
  }
446
652
 
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
653
  function findLatestTranscriptEntry<TType extends BtwTranscriptEntry["type"]>(
468
654
  state: BtwTranscriptState,
469
655
  turnId: number,
@@ -746,7 +932,10 @@ function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEven
746
932
  return;
747
933
  }
748
934
  case "turn_end": {
749
- finishTranscriptTurn(state);
935
+ const stopReason = event.message.role === "assistant" ? event.message.stopReason : "stop";
936
+ const outcome: BtwTurnOutcome =
937
+ stopReason === "aborted" ? "aborted" : stopReason === "error" ? "failed" : "completed";
938
+ finishTranscriptTurn(state, undefined, outcome);
750
939
  return;
751
940
  }
752
941
  default:
@@ -767,7 +956,7 @@ function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDe
767
956
  function setTranscriptFailure(state: BtwTranscriptState, message: string): void {
768
957
  const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state);
769
958
  upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false);
770
- finishTranscriptTurn(state, turnId);
959
+ finishTranscriptTurn(state, turnId, "failed");
771
960
  }
772
961
 
773
962
  function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
@@ -779,10 +968,26 @@ function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
779
968
  }
780
969
 
781
970
  function getCompletedExchangeCount(entries: BtwTranscript): number {
782
- return entries.filter((entry) => entry.type === "assistant-text" && !entry.streaming).length;
971
+ const completedTurnIds = new Set(
972
+ entries.flatMap((entry) =>
973
+ entry.type === "turn-boundary" &&
974
+ entry.phase === "end" &&
975
+ (entry.outcome === undefined || entry.outcome === "completed")
976
+ ? [entry.turnId]
977
+ : [],
978
+ ),
979
+ );
980
+ return entries.filter(
981
+ (entry) => entry.type === "assistant-text" && !entry.streaming && completedTurnIds.has(entry.turnId),
982
+ ).length;
783
983
  }
784
984
 
785
- function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext["ui"]["theme"]): string[] {
985
+ function buildOverlayTranscript(
986
+ entries: BtwTranscript,
987
+ theme: ExtensionContext["ui"]["theme"],
988
+ markdownTheme: MarkdownTheme,
989
+ contentWidth: number,
990
+ ): string[] {
786
991
  if (entries.length === 0) {
787
992
  return [theme.fg("dim", "No BTW thread yet. Ask a side question to start one.")];
788
993
  }
@@ -793,7 +998,7 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
793
998
  const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning");
794
999
  const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success");
795
1000
  const separator = theme.fg("borderMuted", "────────────────────────────────────────");
796
- const blockIndent = " ";
1001
+ const blockIndent = BTW_BLOCK_INDENT;
797
1002
  const resultIndent = blockIndent;
798
1003
 
799
1004
  const pushBlankLine = () => {
@@ -854,9 +1059,17 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
854
1059
 
855
1060
  if (entry.type === "thinking") {
856
1061
  const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge;
857
- pushStackedBlock(thinkingHeader, entry.text, {
858
- style: (line) => theme.fg("warning", theme.italic(line)),
859
- });
1062
+ const markdownLines = new Markdown(entry.text, 0, 0, markdownTheme, {
1063
+ color: (text: string) => theme.fg("warning", text),
1064
+ italic: true,
1065
+ })
1066
+ .render(Math.max(1, contentWidth))
1067
+ .map((line) => line.replace(/\s+$/u, ""));
1068
+ pushBlankLine();
1069
+ lines.push(thinkingHeader);
1070
+ for (const line of markdownLines) {
1071
+ lines.push(line ? `${blockIndent}${line}` : "");
1072
+ }
860
1073
  continue;
861
1074
  }
862
1075
 
@@ -884,7 +1097,14 @@ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext[
884
1097
 
885
1098
  if (entry.type === "assistant-text") {
886
1099
  const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge;
887
- pushStackedBlock(assistantHeader, entry.text);
1100
+ const markdownLines = new Markdown(entry.text, 0, 0, markdownTheme)
1101
+ .render(Math.max(1, contentWidth))
1102
+ .map((line) => line.replace(/\s+$/u, ""));
1103
+ pushBlankLine();
1104
+ lines.push(assistantHeader);
1105
+ for (const line of markdownLines) {
1106
+ lines.push(line ? `${blockIndent}${line}` : "");
1107
+ }
888
1108
  }
889
1109
  }
890
1110
 
@@ -908,7 +1128,7 @@ type BtwHandoffExchange = {
908
1128
  };
909
1129
 
910
1130
  function buildBtwMessageContent(question: string, answer: string): string {
911
- return `Q: ${question}\n\nA: ${answer}`;
1131
+ return `**Question**\n\n${question}\n\n**Answer**\n\n${answer}`;
912
1132
  }
913
1133
 
914
1134
  function formatThread(thread: BtwHandoffExchange[]): string {
@@ -932,18 +1152,18 @@ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffE
932
1152
  const exchanges: BtwHandoffExchange[] = [];
933
1153
  let currentUser = "";
934
1154
  let currentAssistant = "";
1155
+ let excludeCurrent = false;
935
1156
 
936
1157
  const pushCurrent = () => {
937
- if (!currentUser && !currentAssistant) {
938
- return;
1158
+ if (!excludeCurrent && (currentUser || currentAssistant)) {
1159
+ exchanges.push({
1160
+ user: currentUser.trim() || "(No user prompt)",
1161
+ assistant: currentAssistant.trim() || "(No assistant response)",
1162
+ });
939
1163
  }
940
-
941
- exchanges.push({
942
- user: currentUser.trim() || "(No user prompt)",
943
- assistant: currentAssistant.trim() || "(No assistant response)",
944
- });
945
1164
  currentUser = "";
946
1165
  currentAssistant = "";
1166
+ excludeCurrent = false;
947
1167
  };
948
1168
 
949
1169
  for (const message of threadMessages) {
@@ -951,18 +1171,25 @@ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffE
951
1171
  continue;
952
1172
  }
953
1173
 
954
- const text = extractMessageText(message).trim();
955
- if (!text) {
956
- continue;
957
- }
958
-
959
1174
  if (message.role === "user") {
1175
+ const text = extractMessageText(message).trim();
1176
+ if (!text) {
1177
+ continue;
1178
+ }
960
1179
  pushCurrent();
961
1180
  currentUser = text;
962
1181
  continue;
963
1182
  }
964
1183
 
965
- currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
1184
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
1185
+ excludeCurrent = true;
1186
+ continue;
1187
+ }
1188
+
1189
+ const text = extractMessageText(message).trim();
1190
+ if (text) {
1191
+ currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
1192
+ }
966
1193
  }
967
1194
 
968
1195
  pushCurrent();
@@ -995,6 +1222,17 @@ function saveVisibleBtwNote(
995
1222
  return "saved";
996
1223
  }
997
1224
 
1225
+ function canRenderBtwOverlay(ctx: ExtensionContext | ExtensionCommandContext): boolean {
1226
+ return ctx.hasUI && ctx.mode === "tui";
1227
+ }
1228
+
1229
+ function notifyInlineQuestionRequired(
1230
+ ctx: ExtensionCommandContext,
1231
+ command: "/btw" | "/btw:tangent" | "/btw:new" | "/btw:ask",
1232
+ ): void {
1233
+ notify(ctx, `${command} cannot open its composer outside Pi's TUI. Pass the question inline instead.`, "warning");
1234
+ }
1235
+
998
1236
  function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
999
1237
  if (ctx.hasUI) {
1000
1238
  ctx.ui.notify(message, level);
@@ -1003,9 +1241,17 @@ function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string
1003
1241
 
1004
1242
  /** Fixed overlay rows outside the transcript viewport (must match render() structure). */
1005
1243
  const BTW_OVERLAY_CHROME_LINES = 9;
1244
+ /** Indent applied to transcript block bodies. */
1245
+ const BTW_BLOCK_INDENT = " ";
1006
1246
 
1007
1247
  function getOverlayTitle(mode: BtwThreadMode): string {
1008
- return mode === "tangent" ? "BTW tangent" : "BTW";
1248
+ if (mode === "tangent") {
1249
+ return "BTW tangent";
1250
+ }
1251
+ if (mode === "readonly") {
1252
+ return "BTW ask · read-only";
1253
+ }
1254
+ return "BTW";
1009
1255
  }
1010
1256
 
1011
1257
  function buildTranscriptBadge(
@@ -1027,14 +1273,19 @@ class BtwOverlayComponent extends Container implements Focusable {
1027
1273
  private readonly readTranscriptEntries: () => BtwTranscript;
1028
1274
  private readonly getStatus: () => string | null;
1029
1275
  private readonly getMode: () => BtwThreadMode;
1276
+ private readonly getWidthMode: () => BtwOverlayWidthMode;
1030
1277
  private readonly onSubmitCallback: (value: string) => void;
1031
1278
  private readonly onDismissCallback: () => void;
1032
1279
  private readonly onUnfocusCallback: () => void;
1280
+ private readonly onToggleWidthCallback: () => void;
1033
1281
  private readonly tui: TUI;
1034
1282
  private readonly theme: ExtensionContext["ui"]["theme"];
1283
+ private readonly markdownTheme: MarkdownTheme;
1284
+ private readonly managesMouseReporting: boolean;
1035
1285
  private transcriptLines: string[] = [];
1036
1286
  private transcriptScrollOffset = 0;
1037
1287
  private transcriptViewportHeight = 8;
1288
+ private contentWidth = 66;
1038
1289
  private followTranscript = true;
1039
1290
  private _focused = false;
1040
1291
  private modeTextValue = "";
@@ -1058,19 +1309,27 @@ class BtwOverlayComponent extends Container implements Focusable {
1058
1309
  readTranscriptEntries: () => BtwTranscript,
1059
1310
  getStatus: () => string | null,
1060
1311
  getMode: () => BtwThreadMode,
1312
+ getWidthMode: () => BtwOverlayWidthMode,
1061
1313
  onSubmit: (value: string) => void,
1062
1314
  onDismiss: () => void,
1063
1315
  onUnfocus: () => void,
1316
+ onToggleWidth: () => void,
1064
1317
  ) {
1065
1318
  super();
1066
1319
  this.tui = tui;
1067
1320
  this.theme = theme;
1321
+ this.markdownTheme = getMarkdownTheme();
1322
+ // Fullscreen Pi owns mouse reporting for the entire terminal session. In
1323
+ // regular mode BTW manages it while the overlay exists.
1324
+ this.managesMouseReporting = tui.mode !== "fullscreen";
1068
1325
  this.readTranscriptEntries = readTranscriptEntries;
1069
1326
  this.getStatus = getStatus;
1070
1327
  this.getMode = getMode;
1328
+ this.getWidthMode = getWidthMode;
1071
1329
  this.onSubmitCallback = onSubmit;
1072
1330
  this.onDismissCallback = onDismiss;
1073
1331
  this.onUnfocusCallback = onUnfocus;
1332
+ this.onToggleWidthCallback = onToggleWidth;
1074
1333
 
1075
1334
  this.modeText = new Text("", 1, 0);
1076
1335
  this.summaryText = new Text("", 1, 0);
@@ -1088,8 +1347,9 @@ class BtwOverlayComponent extends Container implements Focusable {
1088
1347
 
1089
1348
  this.hintsText = new Text("", 1, 0);
1090
1349
 
1091
- // Enable SGR mouse reporting so wheel/touchpad events reach handleInput().
1092
- this.tui.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
1350
+ if (this.managesMouseReporting) {
1351
+ this.tui.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
1352
+ }
1093
1353
 
1094
1354
  const originalHandleInput = this.input.handleInput.bind(this.input);
1095
1355
  this.input.handleInput = (data: string) => {
@@ -1114,17 +1374,33 @@ class BtwOverlayComponent extends Container implements Focusable {
1114
1374
  this.refresh();
1115
1375
  }
1116
1376
 
1377
+ private get borderless(): boolean {
1378
+ // Full-width mode drops the vertical bars and corner glyphs so a terminal
1379
+ // Shift+drag selection captures only the dialog's own text — with side
1380
+ // borders, the leftmost/rightmost columns would land inside the drag.
1381
+ return this.getWidthMode() === "full";
1382
+ }
1383
+
1117
1384
  private frameLine(content: string, innerWidth: number): string {
1118
1385
  const truncated = truncateToWidth(content, innerWidth, "");
1119
1386
  const padding = Math.max(0, innerWidth - visibleWidth(truncated));
1387
+ if (this.borderless) {
1388
+ return `${truncated}${" ".repeat(padding)}`;
1389
+ }
1120
1390
  return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
1121
1391
  }
1122
1392
 
1123
1393
  private ruleLine(innerWidth: number): string {
1394
+ if (this.borderless) {
1395
+ return this.theme.fg("border", "─".repeat(innerWidth));
1396
+ }
1124
1397
  return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
1125
1398
  }
1126
1399
 
1127
1400
  private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
1401
+ if (this.borderless) {
1402
+ return this.theme.fg("border", "─".repeat(innerWidth));
1403
+ }
1128
1404
  const left = edge === "top" ? "┌" : "└";
1129
1405
  const right = edge === "top" ? "┐" : "┘";
1130
1406
  return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
@@ -1156,7 +1432,9 @@ class BtwOverlayComponent extends Container implements Focusable {
1156
1432
  }
1157
1433
 
1158
1434
  dispose(): void {
1159
- this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
1435
+ if (this.managesMouseReporting) {
1436
+ this.tui.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
1437
+ }
1160
1438
  }
1161
1439
 
1162
1440
  private getMouseScrollDelta(data: string): number | null {
@@ -1179,6 +1457,11 @@ class BtwOverlayComponent extends Container implements Focusable {
1179
1457
  return;
1180
1458
  }
1181
1459
 
1460
+ if (matchesBtwWidthToggle(data)) {
1461
+ this.onToggleWidthCallback();
1462
+ return;
1463
+ }
1464
+
1182
1465
  const mouseScrollDelta = this.getMouseScrollDelta(data);
1183
1466
  if (mouseScrollDelta !== null) {
1184
1467
  this.scrollTranscript(mouseScrollDelta);
@@ -1201,7 +1484,8 @@ class BtwOverlayComponent extends Container implements Focusable {
1201
1484
  }
1202
1485
 
1203
1486
  private inputFrameLine(dialogWidth: number): string {
1204
- const targetWidth = Math.max(1, dialogWidth - 2);
1487
+ const borderColumns = this.borderless ? 0 : 2;
1488
+ const targetWidth = Math.max(1, dialogWidth - borderColumns);
1205
1489
  const previousFocused = this.input.focused;
1206
1490
  // Input.render() emits CURSOR_MARKER when focused. In overlay mode that APC marker
1207
1491
  // can skew width/composition on this one row before the TUI strips it, producing a
@@ -1212,6 +1496,9 @@ class BtwOverlayComponent extends Container implements Focusable {
1212
1496
  const renderedInputLine = this.input.render(targetWidth)[0] ?? "";
1213
1497
  const inputLine = truncateToWidth(renderedInputLine, targetWidth, "");
1214
1498
  const padding = Math.max(0, targetWidth - visibleWidth(inputLine));
1499
+ if (this.borderless) {
1500
+ return `${inputLine}${" ".repeat(padding)}`;
1501
+ }
1215
1502
  return `${this.theme.fg("border", "│")}${inputLine}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
1216
1503
  } finally {
1217
1504
  this.input.focused = previousFocused;
@@ -1224,7 +1511,13 @@ class BtwOverlayComponent extends Container implements Focusable {
1224
1511
 
1225
1512
  override render(width: number): string[] {
1226
1513
  const dialogWidth = Math.max(24, width);
1227
- const innerWidth = Math.max(22, dialogWidth - 2);
1514
+ const borderColumns = this.borderless ? 0 : 2;
1515
+ const innerWidth = Math.max(22, dialogWidth - borderColumns);
1516
+ const contentWidth = Math.max(1, innerWidth - BTW_BLOCK_INDENT.length);
1517
+ if (contentWidth !== this.contentWidth) {
1518
+ this.contentWidth = contentWidth;
1519
+ this.rebuildTranscriptLines();
1520
+ }
1228
1521
  const transcriptLines = this.wrapTranscript(innerWidth);
1229
1522
  const dialogHeight = this.getDialogHeight();
1230
1523
  const chromeHeight = BTW_OVERLAY_CHROME_LINES;
@@ -1288,6 +1581,15 @@ class BtwOverlayComponent extends Container implements Focusable {
1288
1581
  return this.readTranscriptEntries().map((entry) => ({ ...entry }));
1289
1582
  }
1290
1583
 
1584
+ private rebuildTranscriptLines(): void {
1585
+ this.transcriptLines = buildOverlayTranscript(
1586
+ this.readTranscriptEntries(),
1587
+ this.theme,
1588
+ this.markdownTheme,
1589
+ this.contentWidth,
1590
+ );
1591
+ }
1592
+
1291
1593
  refresh(): void {
1292
1594
  this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
1293
1595
  this.modeText.setText(this.modeTextValue);
@@ -1297,7 +1599,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1297
1599
  this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`;
1298
1600
  this.summaryText.setText(this.summaryTextValue);
1299
1601
 
1300
- this.transcriptLines = buildOverlayTranscript(entries, this.theme);
1602
+ this.rebuildTranscriptLines();
1301
1603
  this.transcript.clear();
1302
1604
  for (const line of this.transcriptLines) {
1303
1605
  this.transcript.addChild(new Text(line, 1, 0));
@@ -1306,7 +1608,7 @@ class BtwOverlayComponent extends Container implements Focusable {
1306
1608
  const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
1307
1609
  this.statusTextValue = status;
1308
1610
  this.statusText.setText(this.statusTextValue);
1309
- this.hintsTextValue = "Scroll wheel ↑↓ PgUp/PgDn · Enter · Alt+/ focus · Esc";
1611
+ this.hintsTextValue = `Scroll wheel ↑↓ PgUp/PgDn · Enter · ${BTW_FOCUS_SHORTCUTS_LABEL} focus · Alt+w width · Esc`;
1310
1612
  this.hintsText.setText(this.hintsTextValue);
1311
1613
  this.tui.requestRender();
1312
1614
  }
@@ -1320,9 +1622,16 @@ export default function (pi: ExtensionAPI) {
1320
1622
  let transcriptState = createEmptyTranscriptState();
1321
1623
  let overlayStatus: string | null = null;
1322
1624
  let overlayDraft = "";
1625
+ let overlayWidthMode: BtwOverlayWidthMode = "window";
1323
1626
  let overlayRuntime: OverlayRuntime | null = null;
1324
1627
  let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
1325
1628
  let activeBtwSession: BtwSessionRuntime | null = null;
1629
+ let btwLifecycleGeneration = 0;
1630
+ let btwSubmissionQueue = Promise.resolve();
1631
+
1632
+ function invalidateBtwLifecycle(): void {
1633
+ btwLifecycleGeneration += 1;
1634
+ }
1326
1635
 
1327
1636
  function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
1328
1637
  const activeCtx = ctx ?? lastUiContext;
@@ -1373,6 +1682,43 @@ export default function (pi: ExtensionAPI) {
1373
1682
  overlayRuntime?.refresh?.();
1374
1683
  }
1375
1684
 
1685
+ function getOverlayOptions(): OverlayOptions {
1686
+ const base: OverlayOptions = {
1687
+ minWidth: 72,
1688
+ maxHeight: "78%",
1689
+ anchor: "top-center",
1690
+ nonCapturing: true,
1691
+ };
1692
+ if (overlayWidthMode === "full") {
1693
+ // Edge-to-edge so a terminal Shift+drag selection captures only the
1694
+ // dialog's own text — nothing from the main screen sits beside it.
1695
+ return { ...base, width: "100%", margin: { top: 1 } };
1696
+ }
1697
+ // Framed "window" look: narrower, inset from the terminal edges.
1698
+ return { ...base, width: "78%", margin: { top: 1, left: 2, right: 2 } };
1699
+ }
1700
+
1701
+ async function toggleOverlayWidth(ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
1702
+ overlayWidthMode = overlayWidthMode === "window" ? "full" : "window";
1703
+
1704
+ // overlayOptions is resolved once at showOverlay time, so a width change
1705
+ // requires tearing down and re-opening the overlay. The close path persists
1706
+ // the draft into overlayDraft, and ensureOverlay restores it on reopen.
1707
+ const wasFocused = overlayRuntime?.handle?.isFocused() ?? true;
1708
+ dismissOverlay();
1709
+ await ensureOverlay(ctx);
1710
+ if (!wasFocused) {
1711
+ overlayRuntime?.handle?.unfocus();
1712
+ overlayRuntime?.refresh?.();
1713
+ }
1714
+ setOverlayStatus(
1715
+ overlayWidthMode === "full"
1716
+ ? "Full-width mode. Shift+drag now selects only the dialog. Alt+w to restore the window."
1717
+ : "Window mode. Alt+w switches to full-width for clean copy selection.",
1718
+ ctx,
1719
+ );
1720
+ }
1721
+
1376
1722
  function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
1377
1723
  if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
1378
1724
  return;
@@ -1439,6 +1785,15 @@ export default function (pi: ExtensionAPI) {
1439
1785
  sessionRuntime.subscriptions.add(unsubscribe);
1440
1786
  }
1441
1787
 
1788
+ function requestBtwSessionAbort(sessionRuntime: BtwSessionRuntime): Promise<void> {
1789
+ sessionRuntime.abortPromise ??= Promise.resolve()
1790
+ .then(() => sessionRuntime.session.abort())
1791
+ .catch(() => {
1792
+ // Ignore abort errors during BTW cancellation/replacement/shutdown.
1793
+ });
1794
+ return sessionRuntime.abortPromise;
1795
+ }
1796
+
1442
1797
  async function disposeBtwSession(): Promise<void> {
1443
1798
  const current = activeBtwSession;
1444
1799
  activeBtwSession = null;
@@ -1447,28 +1802,45 @@ export default function (pi: ExtensionAPI) {
1447
1802
  }
1448
1803
 
1449
1804
  clearBtwSessionSubscriptions(current);
1450
-
1451
- try {
1452
- await current.session.abort();
1453
- } catch {
1454
- // Ignore abort errors during BTW session replacement/shutdown.
1455
- }
1456
-
1805
+ await requestBtwSessionAbort(current);
1457
1806
  current.session.dispose();
1458
1807
  }
1459
1808
 
1460
1809
  async function dismissOverlaySession(): Promise<void> {
1810
+ invalidateBtwLifecycle();
1461
1811
  dismissOverlay();
1462
1812
  await disposeBtwSession();
1463
1813
  }
1464
1814
 
1815
+ /**
1816
+ * Escape behaves differently depending on whether the BTW side session is
1817
+ * currently doing work:
1818
+ *
1819
+ * - streaming: the first Escape aborts the in-flight request but keeps the
1820
+ * overlay open (so the partial transcript stays readable and the thread
1821
+ * remains usable). A second Escape dismisses, even while cancellation settles.
1822
+ * - idle: Escape dismisses the overlay immediately (previous behavior).
1823
+ */
1824
+ async function dismissOrAbortOverlaySession(): Promise<void> {
1825
+ const sessionRuntime = activeBtwSession;
1826
+ if (sessionRuntime?.session.isStreaming && !sessionRuntime.abortPromise) {
1827
+ setOverlayStatus("⏹ Aborting. Press Esc again to dismiss the BTW overlay.");
1828
+ await requestBtwSessionAbort(sessionRuntime);
1829
+ if (activeBtwSession === sessionRuntime && overlayRuntime) {
1830
+ setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.");
1831
+ }
1832
+ return;
1833
+ }
1834
+ await dismissOverlaySession();
1835
+ }
1836
+
1465
1837
  async function resolveBtwModel(
1466
1838
  ctx: ExtensionCommandContext,
1467
1839
  notifyOnFallback = false,
1468
1840
  ): Promise<ResolvedBtwModel> {
1469
1841
  if (btwModelOverride) {
1470
1842
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride);
1471
- if (auth.ok && auth.apiKey) {
1843
+ if (hasUsableModelAuth(ctx, btwModelOverride, auth)) {
1472
1844
  return {
1473
1845
  model: btwModelOverride,
1474
1846
  source: "override",
@@ -1559,6 +1931,7 @@ export default function (pi: ExtensionAPI) {
1559
1931
  }
1560
1932
 
1561
1933
  async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise<void> {
1934
+ invalidateBtwLifecycle();
1562
1935
  btwModelOverride = nextModel;
1563
1936
  const details: BtwModelOverrideDetails = nextModel
1564
1937
  ? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api }
@@ -1577,6 +1950,7 @@ export default function (pi: ExtensionAPI) {
1577
1950
  ctx: ExtensionCommandContext,
1578
1951
  nextThinkingLevel: SessionThinkingLevel | null,
1579
1952
  ): Promise<void> {
1953
+ invalidateBtwLifecycle();
1580
1954
  btwThinkingOverride = nextThinkingLevel;
1581
1955
  const details: BtwThinkingOverrideDetails = nextThinkingLevel
1582
1956
  ? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel }
@@ -1591,32 +1965,38 @@ export default function (pi: ExtensionAPI) {
1591
1965
  notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info");
1592
1966
  }
1593
1967
 
1594
- async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime> {
1595
- const settings = await resolveBtwSettings(ctx, true);
1968
+ async function createBtwSubSession(
1969
+ ctx: ExtensionCommandContext,
1970
+ mode: BtwThreadMode,
1971
+ settings: ResolvedBtwSettings,
1972
+ ): Promise<BtwSessionRuntime> {
1596
1973
  if (!settings.model) {
1597
1974
  throw new Error(settings.fallbackReason || "No active model selected.");
1598
1975
  }
1599
1976
 
1600
- const { session } = await createAgentSession({
1977
+ const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, settings.model);
1978
+
1979
+ const sessionOptions: CreateAgentSessionOptions = {
1601
1980
  sessionManager: SessionManager.inMemory(),
1602
1981
  model: settings.model,
1603
- modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
1982
+ ...modelRuntimeOptions,
1604
1983
  thinkingLevel: settings.thinkingLevel,
1605
- // Match pi's default coding-agent toolset (read/bash/edit/write).
1606
- tools: ["read", "bash", "edit", "write"],
1984
+ // Read-only mode narrows this to pi's built-in read-only toolset.
1985
+ tools: [...BTW_TOOLS_BY_MODE[mode]],
1607
1986
  resourceLoader: createBtwResourceLoader(ctx),
1608
- });
1987
+ };
1988
+ const { session } = await createAgentSession(sessionOptions);
1609
1989
 
1610
1990
  const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
1611
1991
  if (seedMessages.length > 0) {
1612
1992
  session.agent.state.messages = seedMessages as typeof session.state.messages;
1613
1993
  }
1614
1994
 
1615
- return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
1995
+ return { session, mode, subscriptions: new Set(), sideThreadStartIndex, promptQueue: Promise.resolve() };
1616
1996
  }
1617
1997
 
1618
1998
  async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
1619
- const settings = await resolveBtwSettings(ctx);
1999
+ const settings = await resolveBtwSettings(ctx, true);
1620
2000
  if (!settings.model) {
1621
2001
  return null;
1622
2002
  }
@@ -1626,12 +2006,12 @@ export default function (pi: ExtensionAPI) {
1626
2006
  }
1627
2007
 
1628
2008
  await disposeBtwSession();
1629
- activeBtwSession = await createBtwSubSession(ctx, mode);
2009
+ activeBtwSession = await createBtwSubSession(ctx, mode, settings);
1630
2010
  return activeBtwSession;
1631
2011
  }
1632
2012
 
1633
2013
  async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
1634
- if (!ctx.hasUI) {
2014
+ if (!canRenderBtwOverlay(ctx)) {
1635
2015
  return;
1636
2016
  }
1637
2017
  lastUiContext = ctx;
@@ -1651,7 +2031,6 @@ export default function (pi: ExtensionAPI) {
1651
2031
  if (activeBtwSession) {
1652
2032
  clearBtwSessionSubscriptions(activeBtwSession);
1653
2033
  }
1654
- runtime.handle?.hide();
1655
2034
  if (overlayRuntime === runtime) {
1656
2035
  overlayRuntime = null;
1657
2036
  }
@@ -1675,16 +2054,20 @@ export default function (pi: ExtensionAPI) {
1675
2054
  () => transcriptState.entries,
1676
2055
  () => overlayStatus,
1677
2056
  () => pendingMode,
2057
+ () => overlayWidthMode,
1678
2058
  (value) => {
1679
2059
  void submitFromOverlay(ctx, value);
1680
2060
  },
1681
2061
  () => {
1682
- void dismissOverlaySession();
2062
+ void dismissOrAbortOverlaySession();
1683
2063
  },
1684
2064
  () => {
1685
2065
  overlayRuntime?.handle?.unfocus();
1686
2066
  overlayRuntime?.refresh?.();
1687
2067
  },
2068
+ () => {
2069
+ void toggleOverlayWidth(ctx);
2070
+ },
1688
2071
  );
1689
2072
 
1690
2073
  overlay.focused = runtime.handle?.isFocused() ?? true;
@@ -1698,7 +2081,6 @@ export default function (pi: ExtensionAPI) {
1698
2081
  };
1699
2082
  runtime.close = () => {
1700
2083
  overlayDraft = overlay.getDraft();
1701
- overlay.dispose();
1702
2084
  closeRuntime();
1703
2085
  };
1704
2086
 
@@ -1712,14 +2094,7 @@ export default function (pi: ExtensionAPI) {
1712
2094
  },
1713
2095
  {
1714
2096
  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
- },
2097
+ overlayOptions: getOverlayOptions(),
1723
2098
  onHandle: (handle) => {
1724
2099
  runtime.handle = handle;
1725
2100
  handle.focus();
@@ -1743,6 +2118,10 @@ export default function (pi: ExtensionAPI) {
1743
2118
  if (name === "btw") {
1744
2119
  const { question, save } = parseBtwArgs(trimmedArgs);
1745
2120
  if (!question) {
2121
+ if (!canRenderBtwOverlay(ctx)) {
2122
+ notifyInlineQuestionRequired(ctx, "/btw");
2123
+ return true;
2124
+ }
1746
2125
  await ensureBtwSession(ctx, pendingMode);
1747
2126
  await ensureOverlay(ctx);
1748
2127
  return true;
@@ -1758,6 +2137,10 @@ export default function (pi: ExtensionAPI) {
1758
2137
 
1759
2138
  if (name === "btw:tangent") {
1760
2139
  const { question, save } = parseBtwArgs(trimmedArgs);
2140
+ if (!question && !canRenderBtwOverlay(ctx)) {
2141
+ notifyInlineQuestionRequired(ctx, "/btw:tangent");
2142
+ return true;
2143
+ }
1761
2144
  if (pendingMode !== "tangent") {
1762
2145
  await resetThread(ctx, true, "tangent");
1763
2146
  }
@@ -1772,9 +2155,37 @@ export default function (pi: ExtensionAPI) {
1772
2155
  return true;
1773
2156
  }
1774
2157
 
2158
+ if (name === "btw:ask") {
2159
+ const { question, save } = parseBtwArgs(trimmedArgs);
2160
+ if (!question && !canRenderBtwOverlay(ctx)) {
2161
+ notifyInlineQuestionRequired(ctx, "/btw:ask");
2162
+ return true;
2163
+ }
2164
+
2165
+ // Read-only mode is a distinct capability boundary, so switching into it
2166
+ // resets the thread and lets ensureBtwSession recreate the child session.
2167
+ if (pendingMode !== "readonly") {
2168
+ await resetThread(ctx, true, "readonly");
2169
+ }
2170
+
2171
+ if (!question) {
2172
+ await ensureBtwSession(ctx, "readonly");
2173
+ await ensureOverlay(ctx);
2174
+ return true;
2175
+ }
2176
+
2177
+ await runBtw(ctx, question, save, "readonly");
2178
+ return true;
2179
+ }
2180
+
1775
2181
  if (name === "btw:new") {
1776
- await resetThread(ctx, true, "contextual");
1777
2182
  const { question, save } = parseBtwArgs(trimmedArgs);
2183
+ if (!question && !canRenderBtwOverlay(ctx)) {
2184
+ notifyInlineQuestionRequired(ctx, "/btw:new");
2185
+ return true;
2186
+ }
2187
+
2188
+ await resetThread(ctx, true, "contextual");
1778
2189
  if (question) {
1779
2190
  await runBtw(ctx, question, save, "contextual");
1780
2191
  } else {
@@ -1840,6 +2251,7 @@ export default function (pi: ExtensionAPI) {
1840
2251
  }
1841
2252
 
1842
2253
  if (name === "btw:inject") {
2254
+ await btwSubmissionQueue;
1843
2255
  if (pendingThread.length === 0) {
1844
2256
  notify(ctx, "No BTW thread to inject.", "warning");
1845
2257
  return true;
@@ -1868,6 +2280,7 @@ export default function (pi: ExtensionAPI) {
1868
2280
  }
1869
2281
 
1870
2282
  if (name === "btw:summarize") {
2283
+ await btwSubmissionQueue;
1871
2284
  if (pendingThread.length === 0) {
1872
2285
  notify(ctx, "No BTW thread to summarize.", "warning");
1873
2286
  return true;
@@ -1901,7 +2314,7 @@ export default function (pi: ExtensionAPI) {
1901
2314
 
1902
2315
  function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
1903
2316
  const trimmed = value.trim();
1904
- const match = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/);
2317
+ const match = trimmed.match(/^\/(btw:(?:new|ask|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/);
1905
2318
  if (!match) {
1906
2319
  return null;
1907
2320
  }
@@ -1943,6 +2356,7 @@ export default function (pi: ExtensionAPI) {
1943
2356
  persist = true,
1944
2357
  mode: BtwThreadMode = "contextual",
1945
2358
  ): Promise<void> {
2359
+ invalidateBtwLifecycle();
1946
2360
  await disposeBtwSession();
1947
2361
  pendingThread = [];
1948
2362
  pendingMode = mode;
@@ -1957,6 +2371,7 @@ export default function (pi: ExtensionAPI) {
1957
2371
  }
1958
2372
 
1959
2373
  async function restoreThread(ctx: ExtensionContext): Promise<void> {
2374
+ invalidateBtwLifecycle();
1960
2375
  await disposeBtwSession();
1961
2376
  pendingThread = [];
1962
2377
  pendingMode = "contextual";
@@ -2031,8 +2446,30 @@ export default function (pi: ExtensionAPI) {
2031
2446
  saveRequested: boolean,
2032
2447
  mode: BtwThreadMode,
2033
2448
  ): Promise<void> {
2449
+ const generation = btwLifecycleGeneration;
2450
+ const submission = btwSubmissionQueue.then(async () => {
2451
+ if (generation !== btwLifecycleGeneration) {
2452
+ return;
2453
+ }
2454
+ await executeBtw(ctx, question, saveRequested, mode, generation);
2455
+ });
2456
+ btwSubmissionQueue = submission.catch(() => {});
2457
+ await submission;
2458
+ }
2459
+
2460
+ async function executeBtw(
2461
+ ctx: ExtensionCommandContext,
2462
+ question: string,
2463
+ saveRequested: boolean,
2464
+ mode: BtwThreadMode,
2465
+ generation: number,
2466
+ ): Promise<void> {
2467
+ const isCurrentGeneration = () => generation === btwLifecycleGeneration;
2034
2468
  lastUiContext = ctx;
2035
2469
  const settings = await resolveBtwSettings(ctx);
2470
+ if (!isCurrentGeneration()) {
2471
+ return;
2472
+ }
2036
2473
  const model = settings.model;
2037
2474
  if (!model) {
2038
2475
  const message = settings.fallbackReason || "No active model selected.";
@@ -2042,7 +2479,10 @@ export default function (pi: ExtensionAPI) {
2042
2479
  }
2043
2480
 
2044
2481
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2045
- if (!auth.ok || !auth.apiKey) {
2482
+ if (!isCurrentGeneration()) {
2483
+ return;
2484
+ }
2485
+ if (!hasUsableModelAuth(ctx, model, auth)) {
2046
2486
  const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
2047
2487
  setOverlayStatus(message, ctx);
2048
2488
  notify(ctx, message, "error");
@@ -2051,6 +2491,12 @@ export default function (pi: ExtensionAPI) {
2051
2491
  }
2052
2492
 
2053
2493
  const sessionRuntime = await ensureBtwSession(ctx, mode);
2494
+ if (!isCurrentGeneration()) {
2495
+ if (sessionRuntime && activeBtwSession === sessionRuntime) {
2496
+ await disposeBtwSession();
2497
+ }
2498
+ return;
2499
+ }
2054
2500
  if (!sessionRuntime) {
2055
2501
  setOverlayStatus("No active model selected.", ctx);
2056
2502
  notify(ctx, "No active model selected.", "error");
@@ -2059,22 +2505,58 @@ export default function (pi: ExtensionAPI) {
2059
2505
 
2060
2506
  const session = sessionRuntime.session;
2061
2507
  const wasBusy = !ctx.isIdle();
2508
+ const overlayAvailable = canRenderBtwOverlay(ctx);
2062
2509
  pendingMode = mode;
2063
2510
  const thinkingLevel = settings.thinkingLevel;
2064
2511
 
2512
+ let releasePromptTurn!: () => void;
2513
+ const previousPromptTurns = sessionRuntime.promptQueue;
2514
+ const currentPromptTurn = new Promise<void>((resolve) => {
2515
+ releasePromptTurn = resolve;
2516
+ });
2517
+ sessionRuntime.promptQueue = previousPromptTurns.then(() => currentPromptTurn);
2518
+
2519
+ if (session.isStreaming || sessionRuntime.abortPromise) {
2520
+ setOverlayStatus("⏳ waiting for the current BTW turn to finish...", ctx);
2521
+ }
2522
+ await previousPromptTurns;
2523
+ if (activeBtwSession !== sessionRuntime) {
2524
+ releasePromptTurn();
2525
+ return;
2526
+ }
2527
+
2528
+ if (sessionRuntime.abortPromise) {
2529
+ setOverlayStatus("⏳ waiting for cancellation to finish...", ctx);
2530
+ await sessionRuntime.abortPromise;
2531
+ if (activeBtwSession !== sessionRuntime) {
2532
+ releasePromptTurn();
2533
+ return;
2534
+ }
2535
+ }
2536
+
2537
+ if (!isCurrentGeneration()) {
2538
+ releasePromptTurn();
2539
+ return;
2540
+ }
2541
+
2542
+ sessionRuntime.abortPromise = undefined;
2065
2543
  setOverlayStatus("⏳ streaming...", ctx);
2066
2544
  await ensureOverlay(ctx);
2067
2545
 
2068
2546
  try {
2069
2547
  await session.prompt(question, { source: "extension" });
2548
+ if (!isCurrentGeneration()) {
2549
+ return;
2550
+ }
2070
2551
 
2071
2552
  const response = getLastAssistantMessage(session);
2072
2553
  if (!response) {
2073
2554
  throw new Error("BTW request finished without a response.");
2074
2555
  }
2075
2556
  if (response.stopReason === "aborted") {
2076
- removeTranscriptTurn(transcriptState, transcriptState.lastTurnId ?? transcriptState.currentTurnId);
2077
- setOverlayStatus("Request aborted.", ctx);
2557
+ const abortedTurnId = transcriptState.currentTurnId ?? transcriptState.lastTurnId;
2558
+ finishTranscriptTurn(transcriptState, abortedTurnId, "aborted");
2559
+ setOverlayStatus("⏹ Aborted. Press Esc again to dismiss the BTW overlay.", ctx);
2078
2560
  return;
2079
2561
  }
2080
2562
  if (response.stopReason === "error") {
@@ -2102,8 +2584,15 @@ export default function (pi: ExtensionAPI) {
2102
2584
  pendingThread.push(details);
2103
2585
  pi.appendEntry(BTW_ENTRY_TYPE, details);
2104
2586
 
2105
- const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
2106
- if (saveState === "saved") {
2587
+ const saveState = saveVisibleBtwNote(pi, details, saveRequested || !overlayAvailable, wasBusy);
2588
+ if (!overlayAvailable) {
2589
+ const message =
2590
+ saveState === "queued"
2591
+ ? "BTW response queued to display after the current turn finishes."
2592
+ : "Displayed BTW response in the session.";
2593
+ notify(ctx, message, "info");
2594
+ setOverlayStatus(message, ctx);
2595
+ } else if (saveState === "saved") {
2107
2596
  notify(ctx, "Saved BTW note to the session.", "info");
2108
2597
  setOverlayStatus("Saved BTW note to the session.", ctx);
2109
2598
  } else if (saveState === "queued") {
@@ -2113,12 +2602,16 @@ export default function (pi: ExtensionAPI) {
2113
2602
  setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
2114
2603
  }
2115
2604
  } catch (error) {
2605
+ if (!isCurrentGeneration()) {
2606
+ return;
2607
+ }
2116
2608
  const errorMessage = error instanceof Error ? error.message : String(error);
2117
2609
  setTranscriptFailure(transcriptState, errorMessage);
2118
2610
  setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
2119
2611
  notify(ctx, errorMessage, "error");
2120
2612
  await disposeBtwSession();
2121
2613
  } finally {
2614
+ releasePromptTurn();
2122
2615
  syncUi(ctx);
2123
2616
  }
2124
2617
  }
@@ -2130,7 +2623,20 @@ export default function (pi: ExtensionAPI) {
2130
2623
  async function getBtwHandoffThread(
2131
2624
  ctx: ExtensionCommandContext,
2132
2625
  ): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> {
2626
+ const pendingSubmissions = btwSubmissionQueue;
2627
+ await pendingSubmissions;
2628
+
2133
2629
  const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode));
2630
+ if (sessionRuntime) {
2631
+ const pendingPromptTurns = sessionRuntime.promptQueue;
2632
+ const pendingAbort = sessionRuntime.abortPromise;
2633
+ await pendingPromptTurns;
2634
+ await pendingAbort;
2635
+ if (activeBtwSession !== sessionRuntime) {
2636
+ throw new Error("BTW session closed before handoff completed.");
2637
+ }
2638
+ }
2639
+
2134
2640
  const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
2135
2641
  const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
2136
2642
 
@@ -2149,18 +2655,21 @@ export default function (pi: ExtensionAPI) {
2149
2655
  }
2150
2656
 
2151
2657
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2152
- if (!auth.ok || !auth.apiKey) {
2658
+ if (!hasUsableModelAuth(ctx, model, auth)) {
2153
2659
  throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
2154
2660
  }
2155
2661
 
2156
- const { session } = await createAgentSession({
2662
+ const modelRuntimeOptions = await createBtwModelRuntimeOptions(ctx, model);
2663
+
2664
+ const sessionOptions: CreateAgentSessionOptions = {
2157
2665
  sessionManager: SessionManager.inMemory(),
2158
2666
  model,
2159
- modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
2667
+ ...modelRuntimeOptions,
2160
2668
  thinkingLevel: "off",
2161
2669
  tools: [],
2162
2670
  resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
2163
- });
2671
+ };
2672
+ const { session } = await createAgentSession(sessionOptions);
2164
2673
 
2165
2674
  try {
2166
2675
  await session.prompt(formatThread(thread), { source: "extension" });
@@ -2197,29 +2706,46 @@ export default function (pi: ExtensionAPI) {
2197
2706
 
2198
2707
  pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
2199
2708
  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];
2709
+ const content = details
2710
+ ? buildBtwMessageContent(details.question, details.answer)
2711
+ : typeof message.content === "string"
2712
+ ? message.content
2713
+ : "[non-text btw message]";
2714
+
2715
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
2716
+ box.addChild(new Text(theme.fg("accent", theme.bold("[BTW]")), 0, 0));
2717
+ box.addChild(
2718
+ new Markdown(content, 0, 0, getMarkdownTheme(), {
2719
+ color: (text: string) => theme.fg("customMessageText", text),
2720
+ }),
2721
+ );
2202
2722
 
2203
2723
  if (expanded && details) {
2204
- lines.push(
2205
- theme.fg(
2206
- "dim",
2207
- `model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`,
2724
+ box.addChild(
2725
+ new Text(
2726
+ theme.fg(
2727
+ "dim",
2728
+ `model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`,
2729
+ ),
2730
+ 0,
2731
+ 0,
2208
2732
  ),
2209
2733
  );
2210
2734
 
2211
2735
  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}`,
2736
+ box.addChild(
2737
+ new Text(
2738
+ theme.fg(
2739
+ "dim",
2740
+ `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`,
2741
+ ),
2742
+ 0,
2743
+ 0,
2216
2744
  ),
2217
2745
  );
2218
2746
  }
2219
2747
  }
2220
2748
 
2221
- const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
2222
- box.addChild(new Text(lines.join("\n"), 0, 0));
2223
2749
  return box;
2224
2750
  });
2225
2751
 
@@ -2238,6 +2764,7 @@ export default function (pi: ExtensionAPI) {
2238
2764
  });
2239
2765
 
2240
2766
  pi.on("session_shutdown", async () => {
2767
+ invalidateBtwLifecycle();
2241
2768
  await disposeBtwSession();
2242
2769
  dismissOverlay();
2243
2770
  });
@@ -2251,6 +2778,16 @@ export default function (pi: ExtensionAPI) {
2251
2778
  });
2252
2779
  }
2253
2780
 
2781
+ pi.registerShortcut(BTW_WIDTH_TOGGLE_SHORTCUT, {
2782
+ description: "Toggle the BTW overlay between window and full-width layouts.",
2783
+ handler: async () => {
2784
+ if (!overlayRuntime || !lastUiContext) {
2785
+ return;
2786
+ }
2787
+ await toggleOverlayWidth(lastUiContext);
2788
+ },
2789
+ });
2790
+
2254
2791
  pi.registerCommand("btw", {
2255
2792
  description: "Continue a side conversation in a focused BTW modal. Add --save to also persist a visible note.",
2256
2793
  handler: async (args, ctx) => {
@@ -2258,6 +2795,13 @@ export default function (pi: ExtensionAPI) {
2258
2795
  },
2259
2796
  });
2260
2797
 
2798
+ pi.registerCommand("side", {
2799
+ description: "Alias for /btw: continue a side conversation in a focused BTW modal.",
2800
+ handler: async (args, ctx) => {
2801
+ await dispatchBtwCommand("btw", args, ctx);
2802
+ },
2803
+ });
2804
+
2261
2805
  pi.registerCommand("btw:tangent", {
2262
2806
  description: "Start or continue a contextless BTW tangent in the focused BTW modal.",
2263
2807
  handler: async (args, ctx) => {
@@ -2265,6 +2809,13 @@ export default function (pi: ExtensionAPI) {
2265
2809
  },
2266
2810
  });
2267
2811
 
2812
+ pi.registerCommand("btw:ask", {
2813
+ description: "Ask a read-only side question: inherits main-session context but exposes only read/grep/find/ls tools.",
2814
+ handler: async (args, ctx) => {
2815
+ await dispatchBtwCommand("btw:ask", args, ctx);
2816
+ },
2817
+ });
2818
+
2268
2819
  pi.registerCommand("btw:new", {
2269
2820
  description: "Start a fresh BTW thread with main-session context. Optionally ask the first question immediately.",
2270
2821
  handler: async (args, ctx) => {
@@ -2307,3 +2858,4 @@ export default function (pi: ExtensionAPI) {
2307
2858
  },
2308
2859
  });
2309
2860
  }
2861
+