pi-btw 0.2.1 → 0.3.8

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/README.md CHANGED
@@ -15,6 +15,7 @@ A small [pi](https://github.com/badlogic/pi-mono) extension that adds a `/btw` s
15
15
  - opens a focused BTW modal shell with its own composer and transcript
16
16
  - keeps the BTW overlay open while you switch focus back to the main editor with `Alt+/`
17
17
  - keeps BTW thread entries out of the main agent's future context
18
+ - supports BTW-only model and thinking overrides without changing the main thread settings
18
19
  - lets you inject the full thread, or a summary of it, back into the main agent
19
20
  - optionally saves an individual BTW exchange as a visible session note with `--save`
20
21
 
@@ -52,6 +53,8 @@ pi install /absolute/path/to/pi-btw
52
53
  /btw --save summarize the last error in one sentence
53
54
  /btw:new let's start a fresh thread about auth
54
55
  /btw:tangent brainstorm from first principles without using the current chat context
56
+ /btw:model openai gpt-5-mini openai-responses
57
+ /btw:thinking low
55
58
  /btw:inject implement the plan we just discussed
56
59
  /btw:summarize turn that side thread into a short handoff
57
60
  /btw:clear
@@ -105,11 +108,26 @@ pi install /absolute/path/to/pi-btw
105
108
 
106
109
  ### `/btw:summarize [instructions]`
107
110
 
108
- - summarizes the BTW thread with the current model
111
+ - summarizes the BTW thread with the current effective BTW model
112
+ - always runs summarize with thinking off, even if BTW chat is using a thinking override
109
113
  - injects the summary into the main agent
110
114
  - if pi is busy, queues it as a follow-up
111
115
  - clears the BTW thread after sending
112
116
 
117
+ ### `/btw:model [<provider> <model> <api> | clear]`
118
+
119
+ - with no args, shows the current effective BTW model and whether it is inherited or overridden
120
+ - with values, sets a BTW-only model override
121
+ - `clear` removes the override and returns BTW to inheriting the main thread model
122
+ - if the configured BTW model has no credentials, BTW warns and falls back to the main thread model
123
+
124
+ ### `/btw:thinking [<level> | clear]`
125
+
126
+ - with no args, shows the current effective BTW thinking level and whether it is inherited or overridden
127
+ - with a value, sets a BTW-only thinking override for normal BTW chat
128
+ - `clear` removes the override and returns BTW to inheriting the main thread thinking level
129
+ - changing `/btw:model` or `/btw:thinking` disposes the current BTW sub-session and applies the new settings on the next BTW prompt while preserving the hidden thread
130
+
113
131
  ## Behavior
114
132
 
115
133
  ### Real sub-session model
@@ -118,6 +136,8 @@ BTW is implemented as an actual pi sub-session with its own in-memory session st
118
136
 
119
137
  - contextual `/btw` threads seed that sub-session from the current main-session branch while filtering out BTW-visible notes from the parent context
120
138
  - `/btw:tangent` starts the same BTW UI in a contextless mode with no inherited main-session conversation
139
+ - BTW can inherit the main thread model/thinking settings or use BTW-only overrides via `/btw:model` and `/btw:thinking`
140
+ - `/btw:summarize` uses the current effective BTW model but keeps thinking off
121
141
  - the overlay transcript/status line is driven from sub-session events, so tool activity, streaming deltas, failures, and recovery are all visible without scraping rendered output
122
142
  - handoff commands (`/btw:inject` and `/btw:summarize`) read from the BTW sub-session thread rather than maintaining a separate manual transcript model
123
143
 
@@ -125,7 +145,7 @@ BTW is implemented as an actual pi sub-session with its own in-memory session st
125
145
 
126
146
  Inside the BTW modal composer, slash handling is split at the BTW/session boundary:
127
147
 
128
- - `/btw:new`, `/btw:tangent`, `/btw:clear`, `/btw:inject`, and `/btw:summarize` stay owned by BTW because they control BTW lifecycle or handoff behavior
148
+ - `/btw:new`, `/btw:tangent`, `/btw:clear`, `/btw:model`, `/btw:thinking`, `/btw:inject`, and `/btw:summarize` stay owned by BTW because they control BTW lifecycle, configuration, or handoff behavior
129
149
  - any other slash-prefixed input is routed through the BTW sub-session's normal `prompt()` path
130
150
  - this means ordinary pi slash commands like `/help` are handled by the sub-session instead of being rejected by a modal-only fallback
131
151
  - if the sub-session cannot handle a slash command, BTW surfaces the real sub-session failure through the transcript/status state instead of inventing an "unsupported slash input" warning
@@ -141,6 +161,7 @@ BTW exchanges are persisted in the session as hidden custom entries so they:
141
161
  - survive reloads and restarts
142
162
  - rehydrate the BTW modal shell for the current branch
143
163
  - preserve whether the current side thread is a normal `/btw` thread or a contextless `/btw:tangent`
164
+ - preserve the current BTW-only model and thinking overrides for that session history
144
165
  - stay out of the main agent's LLM context
145
166
 
146
167
  ### Visible saved notes
package/extensions/btw.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type ExtensionContext,
12
12
  type ResourceLoader,
13
13
  } from "@mariozechner/pi-coding-agent";
14
- import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel } from "@mariozechner/pi-ai";
14
+ import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel, type UserMessage } from "@mariozechner/pi-ai";
15
15
  import {
16
16
  Box,
17
17
  Container,
@@ -31,6 +31,8 @@ import {
31
31
  const BTW_MESSAGE_TYPE = "btw-note";
32
32
  const BTW_ENTRY_TYPE = "btw-thread-entry";
33
33
  const BTW_RESET_TYPE = "btw-thread-reset";
34
+ const BTW_MODEL_OVERRIDE_TYPE = "btw-model-override";
35
+ const BTW_THINKING_OVERRIDE_TYPE = "btw-thinking-override";
34
36
  const BTW_FOCUS_SHORTCUTS = [Key.alt("/"), Key.ctrlAlt("w")] as const;
35
37
 
36
38
  function matchesBtwFocusShortcut(data: string): boolean {
@@ -53,6 +55,7 @@ const BTW_CONTINUE_THREAD_ASSISTANT_TEXT = "Understood, continuing our side conv
53
55
 
54
56
  type SessionThinkingLevel = "off" | AiThinkingLevel;
55
57
  type BtwThreadMode = "contextual" | "tangent";
58
+ type SessionModel = NonNullable<ExtensionCommandContext["model"]>;
56
59
 
57
60
  type BtwDetails = {
58
61
  question: string;
@@ -60,6 +63,7 @@ type BtwDetails = {
60
63
  answer: string;
61
64
  provider: string;
62
65
  model: string;
66
+ api: string;
63
67
  thinkingLevel: SessionThinkingLevel;
64
68
  timestamp: number;
65
69
  usage?: AssistantMessage["usage"];
@@ -77,6 +81,30 @@ type BtwResetDetails = {
77
81
  mode?: BtwThreadMode;
78
82
  };
79
83
 
84
+ type BtwModelOverrideDetails =
85
+ | ({ timestamp: number; action: "set" } & Pick<SessionModel, "provider" | "id" | "api">)
86
+ | { timestamp: number; action: "clear" };
87
+
88
+ type BtwThinkingOverrideDetails =
89
+ | { timestamp: number; action: "set"; thinkingLevel: SessionThinkingLevel }
90
+ | { timestamp: number; action: "clear" };
91
+
92
+ type ResolvedBtwModel = {
93
+ model: SessionModel | null;
94
+ source: "override" | "main" | "none";
95
+ configuredOverride: SessionModel | null;
96
+ fallbackReason?: string;
97
+ };
98
+
99
+ type ResolvedBtwSettings = {
100
+ model: SessionModel | null;
101
+ modelSource: "override" | "main" | "none";
102
+ configuredModelOverride: SessionModel | null;
103
+ thinkingLevel: SessionThinkingLevel;
104
+ thinkingSource: "override" | "main";
105
+ fallbackReason?: string;
106
+ };
107
+
80
108
  type BtwTranscriptEntry =
81
109
  | { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end" }
82
110
  | { id: number; turnId: number; type: "user-message"; text: string }
@@ -152,7 +180,6 @@ function createBtwResourceLoader(
152
180
  getAgentsFiles: () => ({ agentsFiles: [] }),
153
181
  getSystemPrompt: () => systemPrompt,
154
182
  getAppendSystemPrompt: () => appendSystemPrompt,
155
- getPathMetadata: () => new Map(),
156
183
  extendResources: () => {},
157
184
  reload: async () => {},
158
185
  };
@@ -186,17 +213,61 @@ function parseBtwArgs(args: string): ParsedBtwArgs {
186
213
  return { question, save };
187
214
  }
188
215
 
216
+ function parseBtwModelArgs(args: string):
217
+ | { action: "show" }
218
+ | { action: "clear" }
219
+ | { action: "set"; model: SessionModel }
220
+ | { action: "invalid"; message: string } {
221
+ const trimmed = args.trim();
222
+ if (!trimmed) {
223
+ return { action: "show" };
224
+ }
225
+
226
+ if (trimmed === "clear") {
227
+ return { action: "clear" };
228
+ }
229
+
230
+ const parts = trimmed.split(/\s+/);
231
+ if (parts.length !== 3) {
232
+ return { action: "invalid", message: "Usage: /btw:model <provider> <model> <api> | clear" };
233
+ }
234
+
235
+ const [provider, id, api] = parts;
236
+ return { action: "set", model: { provider, id, api } };
237
+ }
238
+
239
+ function parseBtwThinkingArgs(args: string):
240
+ | { action: "show" }
241
+ | { action: "clear" }
242
+ | { action: "set"; thinkingLevel: SessionThinkingLevel } {
243
+ const trimmed = args.trim();
244
+ if (!trimmed) {
245
+ return { action: "show" };
246
+ }
247
+
248
+ if (trimmed === "clear") {
249
+ return { action: "clear" };
250
+ }
251
+
252
+ return { action: "set", thinkingLevel: trimmed as SessionThinkingLevel };
253
+ }
254
+
255
+ function formatModelRef(model: Pick<SessionModel, "provider" | "id" | "api">): string {
256
+ return `${model.provider}/${model.id} (${model.api})`;
257
+ }
258
+
189
259
  function buildBtwSeedState(
190
260
  ctx: ExtensionCommandContext,
191
261
  thread: BtwDetails[],
192
262
  mode: BtwThreadMode,
263
+ sessionModel: SessionModel | null,
193
264
  ): { messages: Message[]; sideThreadStartIndex: number } {
194
265
  const messages: Message[] = [];
195
266
 
196
267
  if (mode === "contextual") {
197
268
  try {
198
269
  messages.push(
199
- ...buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages.filter(
270
+ ...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter(
200
271
  (message) => !isVisibleBtwMessage(message),
201
272
  ),
202
273
  );
@@ -207,7 +278,7 @@ function buildBtwSeedState(
207
278
  return [];
208
279
  }
209
280
 
210
- const message = entry as Partial<Message> & { role?: string; customType?: string; content?: unknown };
281
+ const message = entry as unknown as Partial<Message> & { role?: string; customType?: string; content?: unknown };
211
282
  if (typeof message.role !== "string" || !Array.isArray(message.content)) {
212
283
  return [];
213
284
  }
@@ -230,9 +301,9 @@ function buildBtwSeedState(
230
301
  {
231
302
  role: "assistant",
232
303
  content: [{ type: "text", text: BTW_CONTINUE_THREAD_ASSISTANT_TEXT }],
233
- provider: ctx.model?.provider ?? "unknown",
234
- model: ctx.model?.id ?? "unknown",
235
- api: ctx.model?.api ?? "openai-responses",
304
+ provider: sessionModel?.provider ?? "unknown",
305
+ model: sessionModel?.id ?? "unknown",
306
+ api: sessionModel?.api ?? "openai-responses",
236
307
  usage: {
237
308
  input: 0,
238
309
  output: 0,
@@ -258,7 +329,7 @@ function buildBtwSeedState(
258
329
  content: [{ type: "text", text: entry.answer }],
259
330
  provider: entry.provider,
260
331
  model: entry.model,
261
- api: ctx.model?.api ?? "openai-responses",
332
+ api: entry.api || sessionModel?.api || ctx.model?.api || "openai-responses",
262
333
  usage:
263
334
  entry.usage ?? {
264
335
  input: 0,
@@ -336,7 +407,7 @@ function ensureTranscriptTurn(state: BtwTranscriptState): number {
336
407
  const turnId = state.nextTurnId++;
337
408
  state.currentTurnId = turnId;
338
409
  state.lastTurnId = turnId;
339
- appendTranscriptEntry(state, { type: "turn-boundary", turnId, phase: "start" });
410
+ appendTranscriptEntry(state, { type: "turn-boundary", turnId, phase: "start" } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
340
411
  return turnId;
341
412
  }
342
413
 
@@ -350,7 +421,7 @@ function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null)
350
421
  (entry) => entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
351
422
  );
352
423
  if (!hasEndBoundary) {
353
- appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" });
424
+ appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
354
425
  }
355
426
 
356
427
  for (const entry of state.entries) {
@@ -415,8 +486,18 @@ function ensureTranscriptTurnForUserMessage(state: BtwTranscriptState): number {
415
486
  return ensureTranscriptTurn(state);
416
487
  }
417
488
 
418
- function extractMessageText(message: { content?: AssistantMessage["content"] }): string {
419
- return Array.isArray(message.content) ? extractText(message.content, "text") : "";
489
+ function extractMessageText(message: { content?: string | AssistantMessage["content"] | UserMessage["content"] }): string {
490
+ if (typeof message.content === "string") {
491
+ return message.content;
492
+ }
493
+ if (!Array.isArray(message.content)) {
494
+ return "";
495
+ }
496
+ return message.content
497
+ .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
498
+ .map((part) => part.text)
499
+ .join("\n")
500
+ .trim();
420
501
  }
421
502
 
422
503
  function upsertUserMessageEntry(state: BtwTranscriptState, turnId: number, text: string): void {
@@ -430,7 +511,7 @@ function upsertUserMessageEntry(state: BtwTranscriptState, turnId: number, text:
430
511
  return;
431
512
  }
432
513
 
433
- appendTranscriptEntry(state, { type: "user-message", turnId, text });
514
+ appendTranscriptEntry(state, { type: "user-message", turnId, text } as Omit<Extract<BtwTranscriptEntry, { type: "user-message" }>, "id">);
434
515
  }
435
516
 
436
517
  function upsertTranscriptTextEntry(
@@ -451,7 +532,7 @@ function upsertTranscriptTextEntry(
451
532
  return;
452
533
  }
453
534
 
454
- appendTranscriptEntry(state, { type, turnId, text, streaming });
535
+ appendTranscriptEntry(state, { type, turnId, text, streaming } as Omit<Extract<BtwTranscriptEntry, { type: "thinking" | "assistant-text" }>, "id">);
455
536
  }
456
537
 
457
538
  function summarizeToolResult(value: unknown, maxLength = 400): { content: string; truncated: boolean } {
@@ -522,7 +603,7 @@ function ensureToolCallEntry(
522
603
  toolCallId,
523
604
  toolName,
524
605
  args,
525
- });
606
+ } as Omit<Extract<BtwTranscriptEntry, { type: "tool-call" }>, "id">);
526
607
  const record = { turnId, callEntryId: callEntry.id };
527
608
  state.toolCalls.set(toolCallId, record);
528
609
  return record;
@@ -561,21 +642,17 @@ function upsertToolResultEntry(
561
642
  truncated,
562
643
  isError,
563
644
  streaming,
564
- });
645
+ } as Omit<Extract<BtwTranscriptEntry, { type: "tool-result" }>, "id">);
565
646
  toolCall.resultEntryId = resultEntry.id;
566
647
  }
567
648
 
568
649
  function applyAssistantMessageToTranscript(
569
650
  state: BtwTranscriptState,
570
651
  turnId: number,
571
- message: AgentSessionEvent extends { message: infer T } ? T : never,
652
+ message: AssistantMessage,
572
653
  streaming: boolean,
573
654
  ): void {
574
- if (!message || typeof message !== "object" || (message as { role?: string }).role !== "assistant") {
575
- return;
576
- }
577
-
578
- const assistantMessage = message as AssistantMessage;
655
+ const assistantMessage = message;
579
656
  const thinking = extractThinking(assistantMessage);
580
657
  const answer = extractMessageText(assistantMessage);
581
658
 
@@ -847,7 +924,7 @@ function isThreadContinuationMarker(messages: Message[], index: number): boolean
847
924
 
848
925
  function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffExchange[] {
849
926
  const handoffMessages = sessionRuntime.session.state.messages.slice(sessionRuntime.sideThreadStartIndex);
850
- const threadMessages = isThreadContinuationMarker(handoffMessages, 0) ? handoffMessages.slice(2) : handoffMessages;
927
+ const threadMessages = isThreadContinuationMarker(handoffMessages as Message[], 0) ? handoffMessages.slice(2) : handoffMessages;
851
928
  const exchanges: BtwHandoffExchange[] = [];
852
929
  let currentUser = "";
853
930
  let currentAssistant = "";
@@ -927,8 +1004,8 @@ function getOverlayTitle(mode: BtwThreadMode): string {
927
1004
  function buildTranscriptBadge(
928
1005
  theme: ExtensionContext["ui"]["theme"],
929
1006
  label: string,
930
- background: string,
931
- foreground: string,
1007
+ background: "userMessageBg" | "toolPendingBg" | "customMessageBg",
1008
+ foreground: "accent" | "warning" | "success",
932
1009
  ): string {
933
1010
  return theme.bg(background, theme.fg(foreground, theme.bold(` ${label} `)));
934
1011
  }
@@ -953,6 +1030,10 @@ class BtwOverlayComponent extends Container implements Focusable {
953
1030
  private transcriptViewportHeight = 8;
954
1031
  private followTranscript = true;
955
1032
  private _focused = false;
1033
+ private modeTextValue = "";
1034
+ private summaryTextValue = "";
1035
+ private statusTextValue = "";
1036
+ private hintsTextValue = "";
956
1037
 
957
1038
  get focused(): boolean {
958
1039
  return this._focused;
@@ -1002,7 +1083,18 @@ class BtwOverlayComponent extends Container implements Focusable {
1002
1083
 
1003
1084
  const originalHandleInput = this.input.handleInput.bind(this.input);
1004
1085
  this.input.handleInput = (data: string) => {
1005
- if (keybindings.matches(data, "selectCancel")) {
1086
+ if (keybindings.matches(data, "app.clear")) {
1087
+ if (this.input.getValue().length > 0) {
1088
+ this.input.setValue("");
1089
+ this.tui.requestRender();
1090
+ return;
1091
+ }
1092
+
1093
+ this.onDismissCallback();
1094
+ return;
1095
+ }
1096
+
1097
+ if (keybindings.matches(data, "tui.select.cancel")) {
1006
1098
  this.onDismissCallback();
1007
1099
  return;
1008
1100
  }
@@ -1111,12 +1203,12 @@ class BtwOverlayComponent extends Container implements Focusable {
1111
1203
  const hiddenBelow = Math.max(0, maxScroll - this.transcriptScrollOffset);
1112
1204
  const summary =
1113
1205
  hiddenAbove || hiddenBelow
1114
- ? `${this.summaryText.text.trim()} · ↑${hiddenAbove} ↓${hiddenBelow}`
1115
- : this.summaryText.text.trim();
1206
+ ? `${this.summaryTextValue.trim()} · ↑${hiddenAbove} ↓${hiddenBelow}`
1207
+ : this.summaryTextValue.trim();
1116
1208
 
1117
1209
  const lines = [this.borderLine(innerWidth, "top")];
1118
1210
 
1119
- lines.push(this.frameLine(this.theme.fg("accent", this.theme.bold(this.modeText.text.trim())), innerWidth));
1211
+ lines.push(this.frameLine(this.theme.fg("accent", this.theme.bold(this.modeTextValue.trim())), innerWidth));
1120
1212
  lines.push(this.frameLine(this.theme.fg("dim", summary), innerWidth));
1121
1213
  lines.push(this.ruleLine(innerWidth));
1122
1214
 
@@ -1128,9 +1220,9 @@ class BtwOverlayComponent extends Container implements Focusable {
1128
1220
  }
1129
1221
 
1130
1222
  lines.push(this.ruleLine(innerWidth));
1131
- lines.push(this.frameLine(this.theme.fg("warning", this.statusText.text.trim()), innerWidth));
1223
+ lines.push(this.frameLine(this.theme.fg("warning", this.statusTextValue.trim()), innerWidth));
1132
1224
  lines.push(this.inputFrameLine(dialogWidth));
1133
- lines.push(this.frameLine(this.theme.fg("dim", this.hintsText.text.trim()), innerWidth));
1225
+ lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth));
1134
1226
  lines.push(this.borderLine(innerWidth, "bottom"));
1135
1227
 
1136
1228
  return lines;
@@ -1150,11 +1242,13 @@ class BtwOverlayComponent extends Container implements Focusable {
1150
1242
  }
1151
1243
 
1152
1244
  refresh(): void {
1153
- this.modeText.setText(`${getOverlayTitle(this.getMode())} · hidden thread preserved`);
1245
+ this.modeTextValue = `${getOverlayTitle(this.getMode())} · hidden thread preserved`;
1246
+ this.modeText.setText(this.modeTextValue);
1154
1247
  const entries = this.readTranscriptEntries();
1155
1248
  const exchanges = getCompletedExchangeCount(entries);
1156
1249
  const active = hasStreamingTranscriptEntry(entries) ? " · streaming" : " · idle";
1157
- this.summaryText.setText(`${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`);
1250
+ this.summaryTextValue = `${exchanges} exchange${exchanges === 1 ? "" : "s"}${active}`;
1251
+ this.summaryText.setText(this.summaryTextValue);
1158
1252
 
1159
1253
  this.transcriptLines = buildOverlayTranscript(entries, this.theme);
1160
1254
  this.transcript.clear();
@@ -1163,8 +1257,10 @@ class BtwOverlayComponent extends Container implements Focusable {
1163
1257
  }
1164
1258
 
1165
1259
  const status = this.getStatus() ?? "Ready. Enter submits; Escape dismisses without clearing.";
1166
- this.statusText.setText(status);
1167
- this.hintsText.setText("Enter submit · Alt+/ toggle focus · Escape dismiss · PgUp/PgDn scroll");
1260
+ this.statusTextValue = status;
1261
+ this.statusText.setText(this.statusTextValue);
1262
+ this.hintsTextValue = "Enter submit · Alt+/ toggle focus · Escape dismiss · PgUp/PgDn scroll";
1263
+ this.hintsText.setText(this.hintsTextValue);
1168
1264
  this.tui.requestRender();
1169
1265
  }
1170
1266
  }
@@ -1172,6 +1268,8 @@ class BtwOverlayComponent extends Container implements Focusable {
1172
1268
  export default function (pi: ExtensionAPI) {
1173
1269
  let pendingThread: BtwDetails[] = [];
1174
1270
  let pendingMode: BtwThreadMode = "contextual";
1271
+ let btwModelOverride: SessionModel | null = null;
1272
+ let btwThinkingOverride: SessionThinkingLevel | null = null;
1175
1273
  let transcriptState = createEmptyTranscriptState();
1176
1274
  let overlayStatus: string | null = null;
1177
1275
  let overlayDraft = "";
@@ -1317,26 +1415,161 @@ export default function (pi: ExtensionAPI) {
1317
1415
  await disposeBtwSession();
1318
1416
  }
1319
1417
 
1418
+ async function resolveBtwModel(
1419
+ ctx: ExtensionCommandContext,
1420
+ notifyOnFallback = false,
1421
+ ): Promise<ResolvedBtwModel> {
1422
+ if (btwModelOverride) {
1423
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride);
1424
+ if (auth.ok && auth.apiKey) {
1425
+ return {
1426
+ model: btwModelOverride,
1427
+ source: "override",
1428
+ configuredOverride: btwModelOverride,
1429
+ };
1430
+ }
1431
+
1432
+ const fallbackReason = ctx.model
1433
+ ? `Configured BTW model ${formatModelRef(btwModelOverride)} has no credentials. Falling back to main model ${formatModelRef(
1434
+ ctx.model,
1435
+ )}.`
1436
+ : `Configured BTW model ${formatModelRef(btwModelOverride)} has no credentials, and no main model is active.`;
1437
+ if (notifyOnFallback) {
1438
+ notify(ctx, fallbackReason, "warning");
1439
+ }
1440
+
1441
+ if (ctx.model) {
1442
+ return {
1443
+ model: ctx.model,
1444
+ source: "main",
1445
+ configuredOverride: btwModelOverride,
1446
+ fallbackReason,
1447
+ };
1448
+ }
1449
+
1450
+ return {
1451
+ model: null,
1452
+ source: "none",
1453
+ configuredOverride: btwModelOverride,
1454
+ fallbackReason,
1455
+ };
1456
+ }
1457
+
1458
+ if (ctx.model) {
1459
+ return {
1460
+ model: ctx.model,
1461
+ source: "main",
1462
+ configuredOverride: null,
1463
+ };
1464
+ }
1465
+
1466
+ return {
1467
+ model: null,
1468
+ source: "none",
1469
+ configuredOverride: null,
1470
+ };
1471
+ }
1472
+
1473
+ async function resolveBtwSettings(
1474
+ ctx: ExtensionCommandContext,
1475
+ notifyOnFallback = false,
1476
+ ): Promise<ResolvedBtwSettings> {
1477
+ const resolvedModel = await resolveBtwModel(ctx, notifyOnFallback);
1478
+ const thinkingLevel = btwThinkingOverride ?? (pi.getThinkingLevel() as SessionThinkingLevel);
1479
+
1480
+ return {
1481
+ model: resolvedModel.model,
1482
+ modelSource: resolvedModel.source,
1483
+ configuredModelOverride: resolvedModel.configuredOverride,
1484
+ thinkingLevel,
1485
+ thinkingSource: btwThinkingOverride ? "override" : "main",
1486
+ fallbackReason: resolvedModel.fallbackReason,
1487
+ };
1488
+ }
1489
+
1490
+ function describeResolvedModel(settings: ResolvedBtwSettings): string {
1491
+ if (!settings.model) {
1492
+ if (settings.configuredModelOverride && settings.fallbackReason) {
1493
+ return `BTW model unavailable. ${settings.fallbackReason}`;
1494
+ }
1495
+ return "BTW model unavailable. No active model selected.";
1496
+ }
1497
+
1498
+ const source =
1499
+ settings.modelSource === "override"
1500
+ ? "override"
1501
+ : settings.configuredModelOverride
1502
+ ? "inherited fallback"
1503
+ : "inherits main thread";
1504
+ return `BTW model: ${formatModelRef(settings.model)} (${source}).${
1505
+ settings.fallbackReason ? ` ${settings.fallbackReason}` : ""
1506
+ }`;
1507
+ }
1508
+
1509
+ function describeResolvedThinking(settings: ResolvedBtwSettings): string {
1510
+ const source = settings.thinkingSource === "override" ? "override" : "inherits main thread";
1511
+ return `BTW thinking: ${settings.thinkingLevel} (${source}).`;
1512
+ }
1513
+
1514
+ async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise<void> {
1515
+ btwModelOverride = nextModel;
1516
+ const details: BtwModelOverrideDetails = nextModel
1517
+ ? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api }
1518
+ : { action: "clear", timestamp: Date.now() };
1519
+ pi.appendEntry(BTW_MODEL_OVERRIDE_TYPE, details);
1520
+ await disposeBtwSession();
1521
+ const settings = await resolveBtwSettings(ctx);
1522
+ const message = nextModel
1523
+ ? `BTW model override set to ${formatModelRef(nextModel)}.`
1524
+ : "BTW model override cleared. BTW now inherits the main thread model.";
1525
+ setOverlayStatus(message, ctx);
1526
+ notify(ctx, `${message} ${describeResolvedModel(settings)}`, "info");
1527
+ }
1528
+
1529
+ async function setBtwThinkingOverride(
1530
+ ctx: ExtensionCommandContext,
1531
+ nextThinkingLevel: SessionThinkingLevel | null,
1532
+ ): Promise<void> {
1533
+ btwThinkingOverride = nextThinkingLevel;
1534
+ const details: BtwThinkingOverrideDetails = nextThinkingLevel
1535
+ ? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel }
1536
+ : { action: "clear", timestamp: Date.now() };
1537
+ pi.appendEntry(BTW_THINKING_OVERRIDE_TYPE, details);
1538
+ await disposeBtwSession();
1539
+ const settings = await resolveBtwSettings(ctx);
1540
+ const message = nextThinkingLevel
1541
+ ? `BTW thinking override set to ${nextThinkingLevel}.`
1542
+ : "BTW thinking override cleared. BTW now inherits the main thread thinking level.";
1543
+ setOverlayStatus(message, ctx);
1544
+ notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info");
1545
+ }
1546
+
1320
1547
  async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime> {
1548
+ const settings = await resolveBtwSettings(ctx, true);
1549
+ if (!settings.model) {
1550
+ throw new Error(settings.fallbackReason || "No active model selected.");
1551
+ }
1552
+
1321
1553
  const { session } = await createAgentSession({
1322
1554
  sessionManager: SessionManager.inMemory(),
1323
- model: ctx.model,
1555
+ model: settings.model,
1324
1556
  modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
1325
- thinkingLevel: pi.getThinkingLevel() as SessionThinkingLevel,
1557
+ thinkingLevel: settings.thinkingLevel,
1326
1558
  tools: codingTools,
1327
1559
  resourceLoader: createBtwResourceLoader(ctx),
1328
1560
  });
1329
1561
 
1330
- const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode);
1562
+ const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
1331
1563
  if (seedMessages.length > 0) {
1332
- session.agent.replaceMessages(seedMessages as typeof session.state.messages);
1564
+ session.agent.state.messages = seedMessages as typeof session.state.messages;
1333
1565
  }
1334
1566
 
1335
1567
  return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
1336
1568
  }
1337
1569
 
1338
1570
  async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
1339
- if (!ctx.model) {
1571
+ const settings = await resolveBtwSettings(ctx);
1572
+ if (!settings.model) {
1340
1573
  return null;
1341
1574
  }
1342
1575
 
@@ -1511,6 +1744,40 @@ export default function (pi: ExtensionAPI) {
1511
1744
  return true;
1512
1745
  }
1513
1746
 
1747
+ if (name === "btw:model") {
1748
+ const parsed = parseBtwModelArgs(trimmedArgs);
1749
+ if (parsed.action === "invalid") {
1750
+ setOverlayStatus(parsed.message, ctx);
1751
+ notify(ctx, parsed.message, "error");
1752
+ return true;
1753
+ }
1754
+
1755
+ if (parsed.action === "show") {
1756
+ const settings = await resolveBtwSettings(ctx);
1757
+ const message = describeResolvedModel(settings);
1758
+ setOverlayStatus(message, ctx);
1759
+ notify(ctx, message, settings.model ? "info" : "warning");
1760
+ return true;
1761
+ }
1762
+
1763
+ await setBtwModelOverride(ctx, parsed.action === "clear" ? null : parsed.model);
1764
+ return true;
1765
+ }
1766
+
1767
+ if (name === "btw:thinking") {
1768
+ const parsed = parseBtwThinkingArgs(trimmedArgs);
1769
+ if (parsed.action === "show") {
1770
+ const settings = await resolveBtwSettings(ctx);
1771
+ const message = describeResolvedThinking(settings);
1772
+ setOverlayStatus(message, ctx);
1773
+ notify(ctx, message, "info");
1774
+ return true;
1775
+ }
1776
+
1777
+ await setBtwThinkingOverride(ctx, parsed.action === "clear" ? null : parsed.thinkingLevel);
1778
+ return true;
1779
+ }
1780
+
1514
1781
  if (name === "btw:inject") {
1515
1782
  if (pendingThread.length === 0) {
1516
1783
  notify(ctx, "No BTW thread to inject.", "warning");
@@ -1573,7 +1840,7 @@ export default function (pi: ExtensionAPI) {
1573
1840
 
1574
1841
  function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
1575
1842
  const trimmed = value.trim();
1576
- const match = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize))(?:\s+(.*))?$/);
1843
+ const match = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/);
1577
1844
  if (!match) {
1578
1845
  return null;
1579
1846
  }
@@ -1596,17 +1863,18 @@ export default function (pi: ExtensionAPI) {
1596
1863
  return;
1597
1864
  }
1598
1865
 
1866
+ const cmdCtx = ctx as ExtensionCommandContext;
1599
1867
  const btwCommand = parseOverlayBtwCommand(question);
1600
1868
  if (btwCommand) {
1601
1869
  setOverlayDraft("");
1602
- await dispatchBtwCommand(btwCommand.name, btwCommand.args, ctx);
1870
+ await dispatchBtwCommand(btwCommand.name, btwCommand.args, cmdCtx);
1603
1871
  return;
1604
1872
  }
1605
1873
 
1606
1874
  setOverlayDraft("");
1607
1875
  setOverlayStatus("⏳ streaming...", ctx);
1608
1876
  syncUi(ctx);
1609
- await runBtw(ctx, question, false, pendingMode);
1877
+ await runBtw(cmdCtx, question, false, pendingMode);
1610
1878
  }
1611
1879
 
1612
1880
  async function resetThread(
@@ -1631,6 +1899,8 @@ export default function (pi: ExtensionAPI) {
1631
1899
  await disposeBtwSession();
1632
1900
  pendingThread = [];
1633
1901
  pendingMode = "contextual";
1902
+ btwModelOverride = null;
1903
+ btwThinkingOverride = null;
1634
1904
  transcriptState = createEmptyTranscriptState();
1635
1905
  overlayDraft = "";
1636
1906
  lastUiContext = ctx;
@@ -1640,9 +1910,29 @@ export default function (pi: ExtensionAPI) {
1640
1910
  let lastResetIndex = -1;
1641
1911
 
1642
1912
  for (let i = 0; i < branch.length; i++) {
1913
+ if (isCustomEntry(branch[i], BTW_MODEL_OVERRIDE_TYPE)) {
1914
+ const details = branch[i].data as BtwModelOverrideDetails | undefined;
1915
+ btwModelOverride =
1916
+ details?.action === "set"
1917
+ ? { provider: details.provider, id: details.id, api: details.api }
1918
+ : details?.action === "clear"
1919
+ ? null
1920
+ : btwModelOverride;
1921
+ }
1922
+
1923
+ if (isCustomEntry(branch[i], BTW_THINKING_OVERRIDE_TYPE)) {
1924
+ const details = branch[i].data as BtwThinkingOverrideDetails | undefined;
1925
+ btwThinkingOverride =
1926
+ details?.action === "set"
1927
+ ? details.thinkingLevel
1928
+ : details?.action === "clear"
1929
+ ? null
1930
+ : btwThinkingOverride;
1931
+ }
1932
+
1643
1933
  if (isCustomEntry(branch[i], BTW_RESET_TYPE)) {
1644
1934
  lastResetIndex = i;
1645
- const details = branch[i].data as BtwResetDetails | undefined;
1935
+ const details = (branch[i] as unknown as { data?: BtwResetDetails }).data;
1646
1936
  pendingMode = details?.mode ?? "contextual";
1647
1937
  }
1648
1938
  }
@@ -1652,13 +1942,18 @@ export default function (pi: ExtensionAPI) {
1652
1942
  continue;
1653
1943
  }
1654
1944
 
1655
- const details = entry.data as BtwDetails | undefined;
1945
+ const details = (entry as unknown as { data?: BtwDetails }).data;
1656
1946
  if (!details?.question || !details.answer) {
1657
1947
  continue;
1658
1948
  }
1659
1949
 
1660
- pendingThread.push(details);
1661
- appendPersistedTranscriptTurn(transcriptState, details);
1950
+ const normalizedDetails: BtwDetails = {
1951
+ ...details,
1952
+ api: details.api || ctx.model?.api || "openai-responses",
1953
+ };
1954
+
1955
+ pendingThread.push(normalizedDetails);
1956
+ appendPersistedTranscriptTurn(transcriptState, normalizedDetails);
1662
1957
  }
1663
1958
 
1664
1959
  syncUi(ctx);
@@ -1671,16 +1966,18 @@ export default function (pi: ExtensionAPI) {
1671
1966
  mode: BtwThreadMode,
1672
1967
  ): Promise<void> {
1673
1968
  lastUiContext = ctx;
1674
- const model = ctx.model;
1969
+ const settings = await resolveBtwSettings(ctx);
1970
+ const model = settings.model;
1675
1971
  if (!model) {
1676
- setOverlayStatus("No active model selected.", ctx);
1677
- notify(ctx, "No active model selected.", "error");
1972
+ const message = settings.fallbackReason || "No active model selected.";
1973
+ setOverlayStatus(message, ctx);
1974
+ notify(ctx, message, "error");
1678
1975
  return;
1679
1976
  }
1680
1977
 
1681
- const apiKey = await ctx.modelRegistry.getApiKey(model);
1682
- if (!apiKey) {
1683
- const message = `No credentials available for ${model.provider}/${model.id}.`;
1978
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
1979
+ if (!auth.ok || !auth.apiKey) {
1980
+ const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
1684
1981
  setOverlayStatus(message, ctx);
1685
1982
  notify(ctx, message, "error");
1686
1983
  await ensureOverlay(ctx);
@@ -1697,7 +1994,7 @@ export default function (pi: ExtensionAPI) {
1697
1994
  const session = sessionRuntime.session;
1698
1995
  const wasBusy = !ctx.isIdle();
1699
1996
  pendingMode = mode;
1700
- const thinkingLevel = pi.getThinkingLevel() as SessionThinkingLevel;
1997
+ const thinkingLevel = settings.thinkingLevel;
1701
1998
 
1702
1999
  setOverlayStatus("⏳ streaming...", ctx);
1703
2000
  await ensureOverlay(ctx);
@@ -1730,6 +2027,7 @@ export default function (pi: ExtensionAPI) {
1730
2027
  answer,
1731
2028
  provider: model.provider,
1732
2029
  model: model.id,
2030
+ api: model.api,
1733
2031
  thinkingLevel,
1734
2032
  timestamp: Date.now(),
1735
2033
  usage: response.usage,
@@ -1778,14 +2076,15 @@ export default function (pi: ExtensionAPI) {
1778
2076
  }
1779
2077
 
1780
2078
  async function summarizeThread(ctx: ExtensionCommandContext, thread: BtwHandoffExchange[]): Promise<string> {
1781
- const model = ctx.model;
2079
+ const settings = await resolveBtwSettings(ctx, true);
2080
+ const model = settings.model;
1782
2081
  if (!model) {
1783
- throw new Error("No active model selected.");
2082
+ throw new Error(settings.fallbackReason || "No active model selected.");
1784
2083
  }
1785
2084
 
1786
- const apiKey = await ctx.modelRegistry.getApiKey(model);
1787
- if (!apiKey) {
1788
- throw new Error(`No credentials available for ${model.provider}/${model.id}.`);
2085
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2086
+ if (!auth.ok || !auth.apiKey) {
2087
+ throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
1789
2088
  }
1790
2089
 
1791
2090
  const { session } = await createAgentSession({
@@ -1837,7 +2136,10 @@ export default function (pi: ExtensionAPI) {
1837
2136
 
1838
2137
  if (expanded && details) {
1839
2138
  lines.push(
1840
- theme.fg("dim", `model: ${details.provider}/${details.model} · thinking: ${details.thinkingLevel}`),
2139
+ theme.fg(
2140
+ "dim",
2141
+ `model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`,
2142
+ ),
1841
2143
  );
1842
2144
 
1843
2145
  if (details.usage) {
@@ -1865,10 +2167,6 @@ export default function (pi: ExtensionAPI) {
1865
2167
  await restoreThread(ctx);
1866
2168
  });
1867
2169
 
1868
- pi.on("session_switch", async (_event, ctx) => {
1869
- await restoreThread(ctx);
1870
- });
1871
-
1872
2170
  pi.on("session_tree", async (_event, ctx) => {
1873
2171
  await restoreThread(ctx);
1874
2172
  });
@@ -1881,7 +2179,7 @@ export default function (pi: ExtensionAPI) {
1881
2179
  for (const shortcut of BTW_FOCUS_SHORTCUTS) {
1882
2180
  pi.registerShortcut(shortcut, {
1883
2181
  description: "Toggle BTW overlay focus while leaving it open.",
1884
- handler: async (_args, _ctx) => {
2182
+ handler: async (_ctx) => {
1885
2183
  toggleOverlayFocus();
1886
2184
  },
1887
2185
  });
@@ -1928,4 +2226,18 @@ export default function (pi: ExtensionAPI) {
1928
2226
  await dispatchBtwCommand("btw:summarize", args, ctx);
1929
2227
  },
1930
2228
  });
2229
+
2230
+ pi.registerCommand("btw:model", {
2231
+ description: "Show, set, or clear the BTW-only model override.",
2232
+ handler: async (args, ctx) => {
2233
+ await dispatchBtwCommand("btw:model", args, ctx);
2234
+ },
2235
+ });
2236
+
2237
+ pi.registerCommand("btw:thinking", {
2238
+ description: "Show, set, or clear the BTW-only thinking override.",
2239
+ handler: async (args, ctx) => {
2240
+ await dispatchBtwCommand("btw:thinking", args, ctx);
2241
+ },
2242
+ });
1931
2243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-btw",
3
- "version": "0.2.1",
3
+ "version": "0.3.8",
4
4
  "description": "A pi extension for parallel side conversations with /btw",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,11 +42,12 @@
42
42
  "image": "https://raw.githubusercontent.com/dbachelder/pi-btw/main/docs/btw-overlay.png"
43
43
  },
44
44
  "peerDependencies": {
45
- "@mariozechner/pi-ai": "*",
46
- "@mariozechner/pi-coding-agent": "*",
47
- "@mariozechner/pi-tui": "*"
45
+ "@mariozechner/pi-ai": "^0.66.1",
46
+ "@mariozechner/pi-coding-agent": "^0.66.1",
47
+ "@mariozechner/pi-tui": "^0.66.1"
48
48
  },
49
49
  "devDependencies": {
50
+ "typescript": "^6.0.2",
50
51
  "vitest": "^4.1.0"
51
52
  }
52
53
  }