@narumitw/pi-btw 0.42.0 → 0.43.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/src/btw.ts CHANGED
@@ -1,22 +1,21 @@
1
- import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import type { Api, Model } from "@earendil-works/pi-ai";
1
+ import {
2
+ type Api,
3
+ clampThinkingLevel,
4
+ getSupportedThinkingLevels,
5
+ type Model,
6
+ } from "@earendil-works/pi-ai";
4
7
  import {
5
8
  BorderedLoader,
6
9
  type ExtensionAPI,
7
10
  type ExtensionCommandContext,
8
- getAgentDir,
9
11
  type KeybindingsManager,
10
12
  type Theme,
11
13
  } from "@earendil-works/pi-coding-agent";
12
14
  import type { Component, TUI } from "@earendil-works/pi-tui";
15
+ import { defineMenu, type MenuContext, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
13
16
  import {
14
- BtwBringToMainPreview,
15
- type BtwBringToMainPreviewAction,
16
17
  type BtwBringToMainSegment,
17
18
  type BtwBringToMainSummary,
18
- BtwMenuSelector,
19
- type BtwMenuSelectorAction,
20
19
  BtwTextRangeSelector,
21
20
  type BtwTextRangeSelectorState,
22
21
  buildQuickBringToMainSegments,
@@ -25,6 +24,18 @@ import {
25
24
  getAnsweredTurns,
26
25
  summarizeBringToMain,
27
26
  } from "./bring-to-main.js";
27
+ import {
28
+ type BtwCommandMenuResult,
29
+ runBtwMenuPreservingEditor,
30
+ showBtwCommandMenu,
31
+ } from "./menu.js";
32
+ import {
33
+ type BtwSettings,
34
+ effectiveRememberThinkingLevelChanges,
35
+ parseBtwModelReference,
36
+ readBtwSettings,
37
+ updateBtwSettings,
38
+ } from "./settings.js";
28
39
  import {
29
40
  BTW_THINKING_LEVELS,
30
41
  type BtwThinkingLevel,
@@ -33,12 +44,22 @@ import {
33
44
  type SideQuestionAuth,
34
45
  type SideThread,
35
46
  } from "./side-thread.js";
47
+ import { sanitizeSingleLine } from "./text.js";
36
48
  import {
37
49
  BtwAnsweringView,
50
+ type BtwThinkingControl,
38
51
  BtwTranscriptPager,
39
52
  type TranscriptPagerAction,
40
53
  } from "./transcript-pager.js";
41
54
 
55
+ export {
56
+ BTW_SETTINGS_FILE,
57
+ type BtwSettings,
58
+ type BtwSettingsLoadResult,
59
+ normalizeBtwSettings,
60
+ parseBtwModelReference,
61
+ readBtwSettings,
62
+ } from "./settings.js";
42
63
  export {
43
64
  BTW_THINKING_LEVELS,
44
65
  type BtwThinkingLevel,
@@ -46,19 +67,9 @@ export {
46
67
  completeSideQuestion,
47
68
  loadCompleteSimple,
48
69
  } from "./side-thread.js";
70
+ export { sanitizeSingleLine } from "./text.js";
49
71
 
50
72
  const MAX_CONTEXT_CHARS = 40_000;
51
- export const BTW_SETTINGS_FILE = "pi-btw.json";
52
-
53
- export interface BtwSettings {
54
- model?: string;
55
- thinkingLevel?: BtwThinkingLevel;
56
- }
57
-
58
- export type BtwSettingsLoadResult =
59
- | { kind: "missing" }
60
- | { kind: "invalid"; reason: string }
61
- | { kind: "loaded"; settings: BtwSettings };
62
73
 
63
74
  interface LoadBtwThinkingLevelOptions {
64
75
  settingsPath?: string;
@@ -87,50 +98,25 @@ export interface ResolvedBtwModel {
87
98
  auth: SideQuestionAuth;
88
99
  }
89
100
 
90
- export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
91
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
92
-
93
- const settings: BtwSettings = {};
94
- if (Object.hasOwn(value, "model")) {
95
- const model = Reflect.get(value, "model");
96
- if (typeof model !== "string" || !parseBtwModelReference(model)) return undefined;
97
- settings.model = model;
98
- }
99
- if (Object.hasOwn(value, "thinkingLevel")) {
100
- const thinkingLevel = Reflect.get(value, "thinkingLevel");
101
- if (!isBtwThinkingLevel(thinkingLevel)) return undefined;
102
- settings.thinkingLevel = thinkingLevel;
103
- }
104
- return settings;
105
- }
106
-
107
- export function parseBtwModelReference(
108
- reference: string,
109
- ): { provider: string; modelId: string } | undefined {
110
- if (/\s/.test(reference)) return undefined;
111
- const separator = reference.indexOf("/");
112
- if (separator <= 0 || separator === reference.length - 1) return undefined;
113
- return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
114
- }
115
-
116
101
  export async function resolveBtwModel({
117
102
  settings,
118
103
  currentModel,
119
104
  modelRegistry,
120
105
  warn,
121
106
  }: ResolveBtwModelOptions): Promise<ResolvedBtwModel | undefined> {
107
+ const reportWarning = (message: string) => warn?.(sanitizeSingleLine(message));
122
108
  if (settings.model) {
123
109
  const fallback = currentModel
124
110
  ? `${currentModel.provider}/${currentModel.id}`
125
111
  : "the current model";
126
112
  const reference = parseBtwModelReference(settings.model);
127
113
  if (!reference) {
128
- warn?.(`pi-btw model ${settings.model} is invalid; falling back to ${fallback}.`);
129
- return resolveBtwModel({ settings: {}, currentModel, modelRegistry, warn });
114
+ reportWarning(`pi-btw model ${settings.model} is invalid; falling back to ${fallback}.`);
115
+ return resolveBtwModel({ settings: {}, currentModel, modelRegistry, warn: reportWarning });
130
116
  }
131
117
  const configuredModel = modelRegistry.find(reference.provider, reference.modelId);
132
118
  if (!configuredModel) {
133
- warn?.(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
119
+ reportWarning(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
134
120
  } else {
135
121
  const sameAsCurrent =
136
122
  configuredModel === currentModel ||
@@ -143,9 +129,11 @@ export async function resolveBtwModel({
143
129
  const auth = await modelRegistry.getApiKeyAndHeaders(configuredModel);
144
130
  if (auth.ok && hasRequestAuth(auth)) return { model: configuredModel, auth };
145
131
  const reason = auth.ok ? "has no request credentials" : auth.error;
146
- warn?.(`pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`);
132
+ reportWarning(
133
+ `pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`,
134
+ );
147
135
  } catch (error: unknown) {
148
- warn?.(
136
+ reportWarning(
149
137
  `pi-btw model ${settings.model} credentials failed (${formatError(error)}); ${fallbackAction}.`,
150
138
  );
151
139
  }
@@ -171,26 +159,6 @@ function hasRequestAuth(auth: SideQuestionAuth): boolean {
171
159
  );
172
160
  }
173
161
 
174
- export async function readBtwSettings(
175
- settingsPath = join(getAgentDir(), BTW_SETTINGS_FILE),
176
- ): Promise<BtwSettingsLoadResult> {
177
- let contents: string;
178
- try {
179
- contents = await readFile(settingsPath, "utf8");
180
- } catch (error: unknown) {
181
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
182
- return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
183
- }
184
-
185
- try {
186
- const settings = normalizeBtwSettings(JSON.parse(contents) as unknown);
187
- if (settings) return { kind: "loaded", settings };
188
- return { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
189
- } catch (error: unknown) {
190
- return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
191
- }
192
- }
193
-
194
162
  export async function loadBtwThinkingLevel(
195
163
  currentThinkingLevel: BtwThinkingLevel,
196
164
  options: LoadBtwThinkingLevelOptions = {},
@@ -202,24 +170,44 @@ export async function loadBtwThinkingLevel(
202
170
  }
203
171
 
204
172
  options.warn?.(
205
- `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id" and thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}". Using current Pi thinking level.`,
173
+ sanitizeSingleLine(
174
+ `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id", thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}", and boolean rememberThinkingLevelChanges. Using current Pi thinking level.`,
175
+ ),
206
176
  );
207
177
  return currentThinkingLevel;
208
178
  }
209
179
 
210
- function isBtwThinkingLevel(value: unknown): value is BtwThinkingLevel {
211
- return BTW_THINKING_LEVELS.includes(value as BtwThinkingLevel);
180
+ function formatError(error: unknown): string {
181
+ return error instanceof Error ? error.message : String(error);
212
182
  }
213
183
 
214
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
215
- return error instanceof Error && "code" in error;
184
+ function notifySafely(
185
+ ctx: ExtensionCommandContext,
186
+ message: string,
187
+ level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
188
+ ): void {
189
+ try {
190
+ ctx.ui.notify(sanitizeSingleLine(message), level);
191
+ } catch {
192
+ // Async command continuations may finish after their ExtensionContext is replaced.
193
+ }
216
194
  }
217
195
 
218
- function formatError(error: unknown): string {
219
- return error instanceof Error ? error.message : String(error);
196
+ export interface BtwExtensionDependencies {
197
+ showCommandMenu?: (
198
+ pi: ExtensionAPI,
199
+ ctx: ExtensionCommandContext,
200
+ ) => Promise<BtwCommandMenuResult>;
201
+ loadSettings?: typeof loadSettingsForCommand;
202
+ resolveModel?: typeof resolveBtwModelWithLoader;
203
+ runThread?: typeof runBtwThread;
220
204
  }
221
205
 
222
- export default function btw(pi: ExtensionAPI) {
206
+ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
207
+ const showCommandMenu = dependencies.showCommandMenu ?? showCommandMenuForBtw;
208
+ const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
209
+ const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
210
+ const runThread = dependencies.runThread ?? runBtwThread;
223
211
  pi.registerCommand("btw", {
224
212
  description: "Ask a quick side question without adding it to the main conversation",
225
213
  handler: async (args, ctx) => {
@@ -228,33 +216,57 @@ export default function btw(pi: ExtensionAPI) {
228
216
  ctx.ui.notify("/btw requires interactive TUI mode", "error");
229
217
  return;
230
218
  }
219
+ if (!question && (await showCommandMenu(pi, ctx)) !== "start") return;
231
220
 
232
- const settings = await loadSettingsForCommand(ctx);
233
- const resolution = await resolveBtwModelWithLoader(settings, ctx);
221
+ const settings = await loadSettings(ctx);
222
+ const resolution = await resolveModel(settings, ctx);
234
223
  if (resolution.kind === "cancelled") {
235
- ctx.ui.notify("Cancelled", "info");
224
+ notifySafely(ctx, "Cancelled", "info");
236
225
  return;
237
226
  }
238
227
  if (resolution.kind === "unavailable") {
239
- ctx.ui.notify("No available model for /btw", "error");
228
+ notifySafely(ctx, "No available model for /btw", "error");
240
229
  return;
241
230
  }
242
231
 
243
- await runBtwThread({
232
+ await runThread({
244
233
  initialQuestion: question || undefined,
245
234
  selected: resolution.selected,
246
235
  thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
236
+ rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
247
237
  ctx,
248
238
  });
249
239
  },
250
240
  });
251
241
  }
252
242
 
243
+ async function showCommandMenuForBtw(
244
+ pi: ExtensionAPI,
245
+ ctx: ExtensionCommandContext,
246
+ ): Promise<BtwCommandMenuResult> {
247
+ const currentModel = ctx.model;
248
+ const availableModels = ctx.modelRegistry.getAll();
249
+ const currentThinkingLevel = pi.getThinkingLevel();
250
+ const loaded = await readBtwSettings();
251
+ const settings = loaded.kind === "loaded" ? loaded.settings : {};
252
+ const configured = settings.model ? parseBtwModelReference(settings.model) : undefined;
253
+ const configuredModel = configured
254
+ ? availableModels.find(
255
+ (model) => model.provider === configured.provider && model.id === configured.modelId,
256
+ )
257
+ : undefined;
258
+ const model = configuredModel ?? currentModel;
259
+ return showBtwCommandMenu(ctx, {
260
+ currentThinkingLevel,
261
+ availableThinkingLevels: model ? getSupportedThinkingLevels(model) : BTW_THINKING_LEVELS,
262
+ });
263
+ }
264
+
253
265
  async function loadSettingsForCommand(ctx: ExtensionCommandContext): Promise<BtwSettings> {
254
266
  const settingsResult = await readBtwSettings();
255
267
  if (settingsResult.kind === "loaded") return settingsResult.settings;
256
268
  if (settingsResult.kind === "invalid") {
257
- ctx.ui.notify(`pi-btw settings ignored: ${settingsResult.reason}`, "warning");
269
+ notifySafely(ctx, `pi-btw settings ignored: ${settingsResult.reason}`, "warning");
258
270
  }
259
271
  return {};
260
272
  }
@@ -282,7 +294,7 @@ async function resolveBtwModelWithLoader(
282
294
  currentModel: ctx.model,
283
295
  modelRegistry: ctx.modelRegistry,
284
296
  warn: (message) => {
285
- if (!settled) ctx.ui.notify(message, "warning");
297
+ if (!settled) notifySafely(ctx, message, "warning");
286
298
  },
287
299
  })
288
300
  .then((selected) => {
@@ -305,10 +317,13 @@ interface RunBtwThreadDependencies {
305
317
  interact?: typeof showThreadComposer;
306
318
  chooseBringToMain?: typeof chooseBringToMain;
307
319
  deliverBringToMain?: typeof loadBringToMainDraft;
320
+ persistThinkingLevel?: (level: BtwThinkingLevel) => Promise<unknown>;
308
321
  }
309
322
 
310
323
  export type BtwThreadResult = { kind: "closed" };
311
324
 
325
+ type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
326
+
312
327
  type BtwBringToMainChoice =
313
328
  | BtwThreadResult
314
329
  | {
@@ -325,6 +340,8 @@ interface RunBtwThreadOptions {
325
340
  initialQuestion?: string;
326
341
  selected: ResolvedBtwModel;
327
342
  thinkingLevel: BtwThinkingLevel;
343
+ rememberThinkingLevelChanges?: boolean;
344
+ settingsPath?: string;
328
345
  ctx: ExtensionCommandContext;
329
346
  dependencies?: RunBtwThreadDependencies;
330
347
  }
@@ -333,6 +350,8 @@ export async function runBtwThread({
333
350
  initialQuestion,
334
351
  selected,
335
352
  thinkingLevel,
353
+ rememberThinkingLevelChanges = false,
354
+ settingsPath,
336
355
  ctx,
337
356
  dependencies = {},
338
357
  }: RunBtwThreadOptions): Promise<BtwThreadResult> {
@@ -340,44 +359,82 @@ export async function runBtwThread({
340
359
  const interact = dependencies.interact ?? showThreadComposer;
341
360
  const chooseBringToMainAction = dependencies.chooseBringToMain ?? chooseBringToMain;
342
361
  const deliverBringToMainDraft = dependencies.deliverBringToMain ?? loadBringToMainDraft;
362
+ const persistThinkingLevel =
363
+ dependencies.persistThinkingLevel ??
364
+ ((level: BtwThinkingLevel) => updateBtwSettings({ thinkingLevel: level }, { settingsPath }));
343
365
  const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
366
+ const thinkingLevels = getSupportedThinkingLevels(selected.model);
367
+ const pendingWrites = new Set<Promise<void>>();
368
+ let activeThinkingLevel = clampThinkingLevel(selected.model, thinkingLevel);
344
369
  let pendingQuestion = initialQuestion;
345
370
  let composerDraft: string | undefined;
346
371
 
347
- while (true) {
348
- if (!pendingQuestion) {
349
- const action = await interact(thread, thread.turns.length > 0, ctx, composerDraft);
350
- if (action.kind === "close") return { kind: "closed" };
351
- if (action.kind === "bringToMain") {
352
- const choice = await chooseBringToMainAction(thread, ctx);
353
- if (choice.kind === "closed") return choice;
354
- if (choice.kind === "back") {
372
+ try {
373
+ while (true) {
374
+ if (!pendingQuestion) {
375
+ const thinking: BtwThreadThinkingControl = {
376
+ level: activeThinkingLevel,
377
+ levels: thinkingLevels,
378
+ onChange: (level) => {
379
+ if (!thinkingLevels.includes(level)) return;
380
+ activeThinkingLevel = level;
381
+ if (!rememberThinkingLevelChanges) return;
382
+ let write!: Promise<void>;
383
+ write = Promise.resolve()
384
+ .then(() => persistThinkingLevel(level))
385
+ .then(() => undefined)
386
+ .catch((error: unknown) => {
387
+ notifySafely(
388
+ ctx,
389
+ `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
390
+ "warning",
391
+ );
392
+ })
393
+ .finally(() => pendingWrites.delete(write));
394
+ pendingWrites.add(write);
395
+ },
396
+ };
397
+ const action = await interact(
398
+ thread,
399
+ thread.turns.length > 0,
400
+ ctx,
401
+ composerDraft,
402
+ thinking,
403
+ );
404
+ if (action.kind === "close") return { kind: "closed" };
405
+ if (action.kind === "bringToMain") {
406
+ const choice = await chooseBringToMainAction(thread, ctx);
407
+ if (choice.kind === "closed") return choice;
408
+ if (choice.kind === "back") {
409
+ composerDraft = action.questionDraft;
410
+ continue;
411
+ }
412
+ const delivery = await deliverBringToMainDraft(choice.draft, ctx, choice.summary);
413
+ if (delivery === "loaded" || delivery === "closed") return { kind: "closed" };
355
414
  composerDraft = action.questionDraft;
356
415
  continue;
357
416
  }
358
- const delivery = await deliverBringToMainDraft(choice.draft, ctx, choice.summary);
359
- if (delivery === "loaded" || delivery === "closed") return { kind: "closed" };
360
- composerDraft = action.questionDraft;
361
- continue;
417
+ composerDraft = undefined;
418
+ pendingQuestion = action.question;
362
419
  }
363
- composerDraft = undefined;
364
- pendingQuestion = action.question;
365
- }
366
420
 
367
- const result = await ask(thread, pendingQuestion, selected, thinkingLevel, ctx);
368
- if (result.kind === "aborted") {
369
- ctx.ui.notify("Cancelled", "info");
370
- return { kind: "closed" };
371
- }
372
- if (result.kind === "error") {
373
- thread.turns.push({
374
- kind: "error",
375
- question: pendingQuestion,
376
- answer: result.message,
377
- });
378
- }
421
+ const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx);
422
+ if (result.kind === "aborted") {
423
+ notifySafely(ctx, "Cancelled", "info");
424
+ return { kind: "closed" };
425
+ }
426
+ if (result.kind === "error") {
427
+ thread.turns.push({
428
+ kind: "error",
429
+ question: pendingQuestion,
430
+ answer: result.message,
431
+ });
432
+ }
379
433
 
380
- pendingQuestion = undefined;
434
+ pendingQuestion = undefined;
435
+ }
436
+ } finally {
437
+ await Promise.allSettled([...pendingWrites]);
381
438
  }
382
439
  }
383
440
 
@@ -391,15 +448,27 @@ type BtwCustomFactory<T> = (
391
448
  async function showBtwCustomPreservingEditor<T>(
392
449
  ctx: ExtensionCommandContext,
393
450
  factory: BtwCustomFactory<T>,
394
- ): Promise<T> {
451
+ ): Promise<T | undefined> {
395
452
  let liveEditorText = ctx.ui.getEditorText();
453
+ let completed = false;
396
454
  const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) =>
397
455
  factory(tui, theme, keybindings, (value) => {
398
- liveEditorText = ctx.ui.getEditorText();
456
+ try {
457
+ liveEditorText = ctx.ui.getEditorText();
458
+ } catch {
459
+ // Keep completion finite if session replacement invalidates the editor context.
460
+ }
461
+ completed = true;
399
462
  done(value);
400
463
  }),
401
464
  );
402
- if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
465
+ if (completed) {
466
+ try {
467
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
468
+ } catch {
469
+ // A replaced context owns a different editor and must not receive stale restoration.
470
+ }
471
+ }
403
472
  return result;
404
473
  }
405
474
 
@@ -501,6 +570,7 @@ export async function chooseBringToMain(
501
570
  return selector;
502
571
  },
503
572
  );
573
+ if (!selectedRange) return { kind: "closed" };
504
574
  if (selectedRange.kind === "closed") return selectedRange;
505
575
  if (selectedRange.kind === "back") break;
506
576
  const preview = await showPreview(ctx, selectedRange.draft, selectedRange.summary);
@@ -518,16 +588,47 @@ export async function chooseBringToMain(
518
588
  }
519
589
  }
520
590
 
591
+ type BtwMenuSelectorAction =
592
+ | { kind: "select"; value: string }
593
+ | { kind: "back" }
594
+ | { kind: "close" };
595
+
596
+ type BtwBringToMainPreviewAction = { kind: "bring" } | { kind: "back" } | { kind: "close" };
597
+
521
598
  async function showBringToMainPreview(
522
599
  ctx: ExtensionCommandContext,
523
600
  draft: string,
524
601
  summary: BtwBringToMainSummary,
525
602
  ): Promise<BtwBringToMainPreviewAction> {
526
- return showBtwCustomPreservingEditor<BtwBringToMainPreviewAction>(
527
- ctx,
528
- (tui, theme, keybindings, done) =>
529
- new BtwBringToMainPreview(tui, theme, keybindings, draft, summary, done),
603
+ let confirmed = false;
604
+ const count = summary.messages === 1 ? "1 message" : `${summary.messages} messages`;
605
+ const lineCount = summary.lines === 1 ? "1 line" : `${summary.lines} lines`;
606
+ const menu = defineMenu<void, "preview", "bring", MenuContext>({
607
+ start: "preview",
608
+ screens: {
609
+ preview: () => ({
610
+ kind: "review",
611
+ title: `Preview · ${count} · ${lineCount} · ~${summary.tokens} tokens`,
612
+ content: draft,
613
+ viewportSize: "adaptive",
614
+ hint: "back",
615
+ confirm: { id: "bring", label: "Bring", action: "bring" },
616
+ }),
617
+ },
618
+ actions: {
619
+ bring: async () => {
620
+ confirmed = true;
621
+ return { kind: "close" } as const;
622
+ },
623
+ },
624
+ });
625
+ const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
626
+ runMenu(menuContext, menu, { getState: () => undefined }),
530
627
  );
628
+ if (confirmed && result.kind === "closed" && result.reason === "close") {
629
+ return { kind: "bring" };
630
+ }
631
+ return terminalBtwMenuAction(result);
531
632
  }
532
633
 
533
634
  async function showBtwMenu(
@@ -536,11 +637,43 @@ async function showBtwMenu(
536
637
  options: readonly string[],
537
638
  initialValue?: string,
538
639
  ): Promise<BtwMenuSelectorAction> {
539
- return showBtwCustomPreservingEditor<BtwMenuSelectorAction>(
540
- ctx,
541
- (tui, theme, keybindings, done) =>
542
- new BtwMenuSelector(tui, theme, keybindings, title, options, done, initialValue),
640
+ const items = options.map((label, index) => ({ id: `option-${index}`, label }));
641
+ const initialIndex = initialValue === undefined ? -1 : options.indexOf(initialValue);
642
+ let selectedValue: string | undefined;
643
+ const menu = defineMenu<void, "choices", "select", MenuContext>({
644
+ start: "choices",
645
+ screens: {
646
+ choices: () => ({
647
+ kind: "choice",
648
+ title,
649
+ items,
650
+ action: "select",
651
+ initialItemId: initialIndex >= 0 ? `option-${initialIndex}` : undefined,
652
+ hint: "back",
653
+ }),
654
+ },
655
+ actions: {
656
+ select: async ({ itemId }: { itemId: string }) => {
657
+ const index = Number.parseInt(itemId.slice("option-".length), 10);
658
+ selectedValue = options[index];
659
+ return selectedValue === undefined
660
+ ? ({ kind: "stay" } as const)
661
+ : ({ kind: "close" } as const);
662
+ },
663
+ },
664
+ });
665
+ const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
666
+ runMenu(menuContext, menu, { getState: () => undefined }),
543
667
  );
668
+ return selectedValue !== undefined && result.kind === "closed" && result.reason === "close"
669
+ ? { kind: "select", value: selectedValue }
670
+ : terminalBtwMenuAction(result);
671
+ }
672
+
673
+ function terminalBtwMenuAction(result: RunMenuResult): { kind: "back" } | { kind: "close" } {
674
+ if (result.kind === "closed") return { kind: result.reason };
675
+ if (result.kind === "error") throw result.error;
676
+ return { kind: "close" };
544
677
  }
545
678
 
546
679
  export async function loadBringToMainDraft(
@@ -621,11 +754,18 @@ async function askThreadQuestion(
621
754
  return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
622
755
  (tui, theme, _keybindings, done) => {
623
756
  let settled = false;
624
- const view = new BtwAnsweringView(tui, theme, thread.turns, question, () => {
625
- if (settled) return;
626
- settled = true;
627
- done({ kind: "aborted" });
628
- });
757
+ const view = new BtwAnsweringView(
758
+ tui,
759
+ theme,
760
+ thread.turns,
761
+ question,
762
+ () => {
763
+ if (settled) return;
764
+ settled = true;
765
+ done({ kind: "aborted" });
766
+ },
767
+ thinkingLevel,
768
+ );
629
769
  completeSideThreadTurn({
630
770
  thread,
631
771
  question,
@@ -648,28 +788,19 @@ async function showThreadComposer(
648
788
  thread: SideThread,
649
789
  startAtBottom: boolean,
650
790
  ctx: ExtensionCommandContext,
651
- initialQuestion?: string,
791
+ initialQuestion: string | undefined,
792
+ thinking: BtwThreadThinkingControl,
652
793
  ): Promise<TranscriptPagerAction> {
653
794
  return ctx.ui.custom<TranscriptPagerAction>(
654
- (tui, theme, _keybindings, done) =>
795
+ (tui, theme, keybindings, done) =>
655
796
  new BtwTranscriptPager(tui, theme, thread.turns, done, {
656
797
  startAtBottom,
657
798
  initialQuestion,
799
+ thinking: { ...thinking, keybindings },
658
800
  }),
659
801
  );
660
802
  }
661
803
 
662
- export function sanitizeSingleLine(text: string) {
663
- return [...text.replace(/[\r\n\t]/g, " ")]
664
- .filter((character) => {
665
- const code = character.charCodeAt(0);
666
- return code > 31 && (code < 127 || code > 159);
667
- })
668
- .join("")
669
- .replace(/ +/g, " ")
670
- .trim();
671
- }
672
-
673
804
  type MessageContentBlock = {
674
805
  type?: string;
675
806
  text?: string;