@narumitw/pi-btw 0.52.0 → 0.53.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/README.md CHANGED
@@ -8,7 +8,8 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
8
8
 
9
9
  ## ✨ Features
10
10
 
11
- - Adds a `/btw` menu for starting or resuming an in-memory side thread or changing pi-btw settings.
11
+ - Adds a `/btw` menu for starting or resuming an in-memory side thread, choosing context from the main session tree, or changing pi-btw settings.
12
+ - Starts a fresh side thread from any persisted main-session branch without switching the main branch.
12
13
  - Keeps `/btw <question>` as a direct fast path that always starts a fresh side thread.
13
14
  - Answers side questions in a dedicated, scrollable full-screen UI.
14
15
  - Keeps mouse-drag copying stable while the main agent continues running in the background.
@@ -59,6 +60,11 @@ Examples:
59
60
  ```
60
61
 
61
62
  Running `/btw` alone opens a menu with **Start side thread** selected first.
63
+ **Start from main thread tree…** opens Pi's native session tree and uses the root-to-selected-entry path, including the selected entry, as the new side thread's context.
64
+ Selecting context does not navigate, fork, append to, or switch the main conversation, and it preserves the main editor draft.
65
+ The selector is a snapshot of entries persisted when it opens, while the resulting side thread keeps an immutable context snapshot even if the main conversation later changes.
66
+ Press `Escape` to return to the `/btw` menu or `Ctrl+C` to close the flow.
67
+ The native tree controls remain available: copying reports success or failure, and an explicit `Shift+L` label edit persists through Pi as the only main-session mutation available from this selector.
62
68
  When the current Pi session has non-empty side threads in memory, **Resume side thread** opens a bounded searchable choice list.
63
69
  Search matches the displayed first question and question count while returning the thread's raw in-memory ID.
64
70
  **Settings** changes the starting thinking level and whether shortcut changes for fixed levels are remembered.
@@ -188,6 +194,7 @@ packages/pi-btw/
188
194
  │ ├── index.ts
189
195
  │ ├── btw.ts
190
196
  │ ├── bring-to-main.ts
197
+ │ ├── main-tree-picker.ts
191
198
  │ ├── menu.ts
192
199
  │ ├── settings.ts
193
200
  │ ├── side-thread.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "directory": "packages/pi-btw"
44
44
  },
45
45
  "dependencies": {
46
- "@narumitw/pi-tui-kit": "^0.54.0"
46
+ "@narumitw/pi-tui-kit": "^0.55.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@earendil-works/pi-ai": "*",
package/src/btw.ts CHANGED
@@ -9,10 +9,7 @@ import {
9
9
  BorderedLoader,
10
10
  type ExtensionAPI,
11
11
  type ExtensionCommandContext,
12
- type KeybindingsManager,
13
- type Theme,
14
12
  } from "@earendil-works/pi-coding-agent";
15
- import type { Component, TUI } from "@earendil-works/pi-tui";
16
13
  import type { MenuContext, RunMenuResult } from "@narumitw/pi-tui-kit";
17
14
  import {
18
15
  type BtwBringToMainSegment,
@@ -26,11 +23,13 @@ import {
26
23
  summarizeBringToMain,
27
24
  } from "./bring-to-main.js";
28
25
  import { type RunBtwFullscreen, runBtwFullscreen } from "./fullscreen-ui.js";
26
+ import { pickMainEntry } from "./main-tree-picker.js";
29
27
  import {
30
28
  type BtwCommandMenuResult,
31
29
  type BtwResumeThreadSummary,
32
30
  runBtwMenuPreservingEditor,
33
31
  showBtwCommandMenu,
32
+ showBtwCustomPreservingEditor,
34
33
  } from "./menu.js";
35
34
  import {
36
35
  type BtwSettings,
@@ -222,6 +221,7 @@ export interface BtwExtensionDependencies {
222
221
  ctx: ExtensionCommandContext,
223
222
  resumeThreads: readonly BtwResumeThreadSummary[],
224
223
  ) => Promise<BtwCommandMenuResult>;
224
+ pickMainEntry?: typeof pickMainEntry;
225
225
  loadSettings?: typeof loadSettingsForCommand;
226
226
  resolveModel?: typeof resolveBtwModelWithLoader;
227
227
  runThread?: typeof runBtwThread;
@@ -230,6 +230,7 @@ export interface BtwExtensionDependencies {
230
230
 
231
231
  export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
232
232
  const showCommandMenu = dependencies.showCommandMenu ?? showCommandMenuForBtw;
233
+ const pickEntry = dependencies.pickMainEntry ?? pickMainEntry;
233
234
  const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
234
235
  const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
235
236
  const runThread = dependencies.runThread ?? runBtwThread;
@@ -259,9 +260,37 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
259
260
  }
260
261
 
261
262
  let menuResult: BtwCommandMenuResult = "start";
263
+ let selectedConversationContext: string | undefined;
262
264
  if (!question) {
263
- menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
264
- if (menuResult === "closed") return;
265
+ while (true) {
266
+ menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
267
+ if (menuResult === "closed") return;
268
+ if (menuResult !== "tree") break;
269
+
270
+ const treeResult = await pickEntry(pi, ctx);
271
+ if (treeResult.kind === "closed") return;
272
+ if (treeResult.kind === "back") continue;
273
+ try {
274
+ if (!ctx.sessionManager.getEntry(treeResult.entryId)) {
275
+ notifySafely(ctx, "The selected main-thread entry is no longer available", "warning");
276
+ continue;
277
+ }
278
+ const branch = ctx.sessionManager.getBranch(treeResult.entryId);
279
+ if (branch.at(-1)?.id !== treeResult.entryId) {
280
+ notifySafely(
281
+ ctx,
282
+ "The selected main-thread branch is no longer available",
283
+ "warning",
284
+ );
285
+ continue;
286
+ }
287
+ selectedConversationContext = buildConversationContext(branch);
288
+ menuResult = "start";
289
+ break;
290
+ } catch {
291
+ return;
292
+ }
293
+ }
265
294
  }
266
295
 
267
296
  const settings = await loadSettings(ctx);
@@ -291,7 +320,8 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
291
320
  state = {
292
321
  id: `btw-${nextThreadNumber}`,
293
322
  thread: createSideThread(
294
- buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
323
+ selectedConversationContext ??
324
+ buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
295
325
  ),
296
326
  thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
297
327
  createdAt,
@@ -546,40 +576,6 @@ export async function runBtwThread({
546
576
  }
547
577
  }
548
578
 
549
- type BtwCustomFactory<T> = (
550
- tui: TUI,
551
- theme: Theme,
552
- keybindings: KeybindingsManager,
553
- done: (result: T) => void,
554
- ) => Component;
555
-
556
- async function showBtwCustomPreservingEditor<T>(
557
- ctx: ExtensionCommandContext,
558
- factory: BtwCustomFactory<T>,
559
- ): Promise<T | undefined> {
560
- let liveEditorText = ctx.ui.getEditorText();
561
- let completed = false;
562
- const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) =>
563
- factory(tui, theme, keybindings, (value) => {
564
- try {
565
- liveEditorText = ctx.ui.getEditorText();
566
- } catch {
567
- // Keep completion finite if session replacement invalidates the editor context.
568
- }
569
- completed = true;
570
- done(value);
571
- }),
572
- );
573
- if (completed) {
574
- try {
575
- if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
576
- } catch {
577
- // A replaced context owns a different editor and must not receive stale restoration.
578
- }
579
- }
580
- return result;
581
- }
582
-
583
579
  interface ChooseBringToMainDependencies {
584
580
  showMenu?: typeof showBtwMenu;
585
581
  showPreview?: typeof showBringToMainPreview;
@@ -0,0 +1,390 @@
1
+ import {
2
+ copyToClipboard,
3
+ type ExtensionAPI,
4
+ type ExtensionCommandContext,
5
+ type SessionEntry,
6
+ type SessionTreeNode,
7
+ TreeSelectorComponent,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { type Component, type Focusable, Key, matchesKey } from "@earendil-works/pi-tui";
10
+ import { showBtwCustomPreservingEditor } from "./menu.js";
11
+ import { sanitizeSingleLine } from "./text.js";
12
+
13
+ export type MainEntryPickResult =
14
+ | { kind: "selected"; entryId: string }
15
+ | { kind: "back" }
16
+ | { kind: "closed" };
17
+
18
+ export interface MainThreadTreeSelector extends Component, Focusable {
19
+ onCopy?: (text: string | undefined) => void;
20
+ setViewLabel?(entryId: string, label: string | undefined, labelTimestamp?: string): void;
21
+ dispose?(): void;
22
+ }
23
+
24
+ export interface MainThreadTreeSelectorOptions {
25
+ tree: SessionTreeNode[];
26
+ currentLeafId: string | null;
27
+ terminalRows: number;
28
+ onSelect: (entryId: string) => void;
29
+ onCancel: () => void;
30
+ onCopy: (entryId: string | undefined, displayText: string | undefined) => void;
31
+ onLabelChange: (entryId: string, label: string | undefined) => void;
32
+ }
33
+
34
+ export interface MainThreadTreePickerDependencies {
35
+ createSelector?: (options: MainThreadTreeSelectorOptions) => MainThreadTreeSelector;
36
+ copyToClipboard?: (text: string, signal: AbortSignal) => Promise<void>;
37
+ }
38
+
39
+ class MainThreadTreePickerComponent implements Component, Focusable {
40
+ constructor(
41
+ private readonly selector: MainThreadTreeSelector,
42
+ private readonly onClose: () => void,
43
+ ) {}
44
+
45
+ get focused(): boolean {
46
+ return this.selector.focused;
47
+ }
48
+
49
+ set focused(value: boolean) {
50
+ this.selector.focused = value;
51
+ }
52
+
53
+ get wantsKeyRelease(): boolean | undefined {
54
+ return this.selector.wantsKeyRelease;
55
+ }
56
+
57
+ render(width: number): string[] {
58
+ return this.selector.render(width);
59
+ }
60
+
61
+ handleInput(data: string): void {
62
+ if (matchesKey(data, Key.ctrl("c"))) {
63
+ this.onClose();
64
+ return;
65
+ }
66
+ this.selector.handleInput?.(data);
67
+ }
68
+
69
+ invalidate(): void {
70
+ this.selector.invalidate();
71
+ }
72
+
73
+ dispose(): void {
74
+ this.selector.dispose?.();
75
+ this.onClose();
76
+ }
77
+ }
78
+
79
+ export async function pickMainEntry(
80
+ pi: ExtensionAPI,
81
+ ctx: ExtensionCommandContext,
82
+ dependencies: MainThreadTreePickerDependencies = {},
83
+ ): Promise<MainEntryPickResult> {
84
+ let rawTree: SessionTreeNode[];
85
+ let currentLeafId: string | null;
86
+ try {
87
+ rawTree = ctx.sessionManager.getTree();
88
+ currentLeafId = ctx.sessionManager.getLeafId();
89
+ } catch {
90
+ return { kind: "closed" };
91
+ }
92
+
93
+ if (rawTree.length === 0) {
94
+ notifySafely(ctx, "No main-thread entries are available", "warning");
95
+ return { kind: "back" };
96
+ }
97
+
98
+ const tree = sanitizeTreeForDisplay(rawTree);
99
+ const rawCopyText = collectRawCopyText(rawTree);
100
+ const savedLabels = collectSavedLabels(rawTree);
101
+ const createSelector = dependencies.createSelector ?? createNativeTreeSelector;
102
+ const copy = dependencies.copyToClipboard ?? copyText;
103
+ const copyControllers = new Set<AbortController>();
104
+ const copyTasks = new Set<Promise<void>>();
105
+ const abortCopies = () => {
106
+ for (const controller of copyControllers) {
107
+ controller.abort(new Error("The main-thread tree picker closed"));
108
+ }
109
+ };
110
+ const result = await showBtwCustomPreservingEditor<MainEntryPickResult>(
111
+ ctx,
112
+ (tui, _theme, _keybindings, done) => {
113
+ let settled = false;
114
+ let selector: MainThreadTreeSelector | undefined;
115
+ const finish = (value: MainEntryPickResult) => {
116
+ if (settled) return;
117
+ settled = true;
118
+ abortCopies();
119
+ done(value);
120
+ };
121
+ const onCopy = (entryId: string | undefined, displayText: string | undefined) => {
122
+ if (settled) return;
123
+ const text = entryId ? rawCopyText.get(entryId) : displayText;
124
+ if (!text) {
125
+ notifySafely(ctx, "Selected entry has no text to copy", "warning");
126
+ return;
127
+ }
128
+ const controller = new AbortController();
129
+ copyControllers.add(controller);
130
+ let operation: Promise<void>;
131
+ try {
132
+ operation = copy(text, controller.signal);
133
+ } catch (error: unknown) {
134
+ operation = Promise.reject(error);
135
+ }
136
+ let task!: Promise<void>;
137
+ task = operation
138
+ .then(() => {
139
+ if (!settled) notifySafely(ctx, "Copied selected message", "info");
140
+ })
141
+ .catch((error: unknown) => {
142
+ if (!settled && !controller.signal.aborted) {
143
+ notifySafely(ctx, `Could not copy selected message: ${formatError(error)}`, "error");
144
+ }
145
+ })
146
+ .finally(() => {
147
+ copyControllers.delete(controller);
148
+ copyTasks.delete(task);
149
+ });
150
+ copyTasks.add(task);
151
+ };
152
+ const restoreLabel = (entryId: string) => {
153
+ const previous = savedLabels.get(entryId);
154
+ selector?.setViewLabel?.(entryId, previous?.label, previous?.labelTimestamp);
155
+ tui.requestRender();
156
+ };
157
+ const onLabelChange = (entryId: string, label: string | undefined) => {
158
+ if (settled) return;
159
+ try {
160
+ if (!ctx.sessionManager.getEntry(entryId)) {
161
+ restoreLabel(entryId);
162
+ notifySafely(ctx, "The selected main-thread entry is no longer available", "warning");
163
+ return;
164
+ }
165
+ const persistedLabel = label === undefined ? undefined : sanitizeSingleLine(label);
166
+ pi.setLabel(entryId, persistedLabel);
167
+ savedLabels.set(entryId, { label: persistedLabel });
168
+ selector?.setViewLabel?.(entryId, persistedLabel);
169
+ tui.requestRender();
170
+ } catch (error: unknown) {
171
+ restoreLabel(entryId);
172
+ notifySafely(ctx, `Could not update tree label: ${formatError(error)}`, "error");
173
+ }
174
+ };
175
+ selector = createSelector({
176
+ tree,
177
+ currentLeafId,
178
+ terminalRows: tui.terminal.rows,
179
+ onSelect: (entryId) => finish({ kind: "selected", entryId }),
180
+ onCancel: () => finish({ kind: "back" }),
181
+ onCopy,
182
+ onLabelChange,
183
+ });
184
+ return new MainThreadTreePickerComponent(selector, () => finish({ kind: "closed" }));
185
+ },
186
+ );
187
+ abortCopies();
188
+ await Promise.allSettled([...copyTasks]);
189
+
190
+ return result ?? { kind: "closed" };
191
+ }
192
+
193
+ function createNativeTreeSelector(options: MainThreadTreeSelectorOptions): MainThreadTreeSelector {
194
+ const selector = new TreeSelectorComponent(
195
+ options.tree,
196
+ options.currentLeafId,
197
+ options.terminalRows,
198
+ options.onSelect,
199
+ options.onCancel,
200
+ options.onLabelChange,
201
+ );
202
+ selector.onCopy = (displayText) =>
203
+ options.onCopy(selector.getTreeList().getSelectedNode()?.entry.id, displayText);
204
+ const result = selector as MainThreadTreeSelector;
205
+ result.setViewLabel = (entryId, label, labelTimestamp) =>
206
+ selector.getTreeList().updateNodeLabel(entryId, label, labelTimestamp);
207
+ return result;
208
+ }
209
+
210
+ function sanitizeTreeForDisplay(tree: readonly SessionTreeNode[]): SessionTreeNode[] {
211
+ return tree.map((node) => {
212
+ const result: SessionTreeNode = {
213
+ entry: sanitizeEntryForDisplay(node.entry),
214
+ children: sanitizeTreeForDisplay(node.children),
215
+ };
216
+ if (node.label !== undefined) result.label = sanitizeSingleLine(node.label);
217
+ if (node.labelTimestamp !== undefined) {
218
+ result.labelTimestamp = sanitizeSingleLine(node.labelTimestamp);
219
+ }
220
+ return result;
221
+ });
222
+ }
223
+
224
+ function sanitizeEntryForDisplay(entry: SessionEntry): SessionEntry {
225
+ switch (entry.type) {
226
+ case "message": {
227
+ const message = { ...entry.message } as Record<string, unknown>;
228
+ if ("content" in entry.message)
229
+ message.content = sanitizeDisplayContent(entry.message.content);
230
+ for (const key of ["role", "errorMessage", "command", "toolName"] as const) {
231
+ const value = message[key];
232
+ if (typeof value === "string") message[key] = sanitizeSingleLine(value);
233
+ }
234
+ return { ...entry, message } as unknown as SessionEntry;
235
+ }
236
+ case "custom_message":
237
+ return {
238
+ ...entry,
239
+ customType: sanitizeSingleLine(entry.customType),
240
+ content: sanitizeDisplayContent(entry.content) as typeof entry.content,
241
+ };
242
+ case "compaction":
243
+ return { ...entry, summary: sanitizeSingleLine(entry.summary) };
244
+ case "branch_summary":
245
+ return { ...entry, summary: sanitizeSingleLine(entry.summary) };
246
+ case "model_change":
247
+ return {
248
+ ...entry,
249
+ provider: sanitizeSingleLine(entry.provider),
250
+ modelId: sanitizeSingleLine(entry.modelId),
251
+ };
252
+ case "thinking_level_change":
253
+ return { ...entry, thinkingLevel: sanitizeSingleLine(entry.thinkingLevel) };
254
+ case "custom":
255
+ return { ...entry, customType: sanitizeSingleLine(entry.customType) };
256
+ case "label":
257
+ return {
258
+ ...entry,
259
+ label: entry.label === undefined ? undefined : sanitizeSingleLine(entry.label),
260
+ };
261
+ case "session_info":
262
+ return {
263
+ ...entry,
264
+ name: entry.name === undefined ? undefined : sanitizeSingleLine(entry.name),
265
+ };
266
+ }
267
+ }
268
+
269
+ function sanitizeDisplayContent(content: unknown): unknown {
270
+ if (typeof content === "string") return sanitizeSingleLine(content);
271
+ if (!Array.isArray(content)) return content;
272
+ return content.map((block) => {
273
+ if (block === null || typeof block !== "object" || !("type" in block)) return block;
274
+ if (block.type === "text" && "text" in block && typeof block.text === "string") {
275
+ return { ...block, text: sanitizeSingleLine(block.text) };
276
+ }
277
+ if (block.type === "toolCall") {
278
+ const copy = { ...block } as Record<string, unknown>;
279
+ if (typeof copy.name === "string") copy.name = sanitizeSingleLine(copy.name);
280
+ copy.arguments = sanitizeToolArguments(copy.arguments, new WeakMap());
281
+ return copy;
282
+ }
283
+ return block;
284
+ });
285
+ }
286
+
287
+ function sanitizeToolArguments(value: unknown, seen: WeakMap<object, unknown>): unknown {
288
+ if (typeof value === "string") return sanitizeSingleLine(value);
289
+ if (value === null || typeof value !== "object") return value;
290
+ const existing = seen.get(value);
291
+ if (existing !== undefined) return existing;
292
+ if (Array.isArray(value)) {
293
+ const result: unknown[] = [];
294
+ seen.set(value, result);
295
+ for (const item of value) result.push(sanitizeToolArguments(item, seen));
296
+ return result;
297
+ }
298
+ const result: Record<string, unknown> = {};
299
+ seen.set(value, result);
300
+ for (const [key, item] of Object.entries(value)) {
301
+ result[key] = sanitizeToolArguments(item, seen);
302
+ }
303
+ return result;
304
+ }
305
+
306
+ function collectRawCopyText(tree: readonly SessionTreeNode[]): Map<string, string> {
307
+ const result = new Map<string, string>();
308
+ const visit = (nodes: readonly SessionTreeNode[]) => {
309
+ for (const node of nodes) {
310
+ const text = getRawCopyText(node.entry);
311
+ if (text !== undefined) result.set(node.entry.id, text);
312
+ visit(node.children);
313
+ }
314
+ };
315
+ visit(tree);
316
+ return result;
317
+ }
318
+
319
+ function getRawCopyText(entry: SessionEntry): string | undefined {
320
+ let text: string | undefined;
321
+ if (entry.type === "message") {
322
+ if (entry.message.role === "bashExecution") text = entry.message.command;
323
+ else if ("content" in entry.message) {
324
+ text = extractRawText(entry.message.content);
325
+ if (!text && entry.message.role === "assistant") text = entry.message.errorMessage;
326
+ }
327
+ } else if (entry.type === "custom_message") text = extractRawText(entry.content);
328
+ else if (entry.type === "compaction" || entry.type === "branch_summary") text = entry.summary;
329
+ return text?.trim() ? text : undefined;
330
+ }
331
+
332
+ function extractRawText(content: unknown): string {
333
+ if (typeof content === "string") return content;
334
+ if (!Array.isArray(content)) return "";
335
+ return content
336
+ .filter(
337
+ (block): block is { type: "text"; text: string } =>
338
+ block !== null &&
339
+ typeof block === "object" &&
340
+ "type" in block &&
341
+ block.type === "text" &&
342
+ "text" in block &&
343
+ typeof block.text === "string",
344
+ )
345
+ .map((block) => block.text)
346
+ .join("");
347
+ }
348
+
349
+ interface SavedLabel {
350
+ label: string | undefined;
351
+ labelTimestamp?: string;
352
+ }
353
+
354
+ function collectSavedLabels(tree: readonly SessionTreeNode[]): Map<string, SavedLabel> {
355
+ const result = new Map<string, SavedLabel>();
356
+ const visit = (nodes: readonly SessionTreeNode[]) => {
357
+ for (const node of nodes) {
358
+ result.set(node.entry.id, {
359
+ label: node.label === undefined ? undefined : sanitizeSingleLine(node.label),
360
+ labelTimestamp:
361
+ node.labelTimestamp === undefined ? undefined : sanitizeSingleLine(node.labelTimestamp),
362
+ });
363
+ visit(node.children);
364
+ }
365
+ };
366
+ visit(tree);
367
+ return result;
368
+ }
369
+
370
+ async function copyText(text: string, signal: AbortSignal): Promise<void> {
371
+ signal.throwIfAborted();
372
+ await copyToClipboard(text);
373
+ signal.throwIfAborted();
374
+ }
375
+
376
+ function notifySafely(
377
+ ctx: ExtensionCommandContext,
378
+ message: string,
379
+ level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
380
+ ): void {
381
+ try {
382
+ ctx.ui.notify(sanitizeSingleLine(message), level);
383
+ } catch {
384
+ // The command context may have been replaced while the selector was open.
385
+ }
386
+ }
387
+
388
+ function formatError(error: unknown): string {
389
+ return error instanceof Error ? error.message : String(error);
390
+ }
package/src/menu.ts CHANGED
@@ -41,10 +41,14 @@ export interface ShowBtwCommandMenuOptions {
41
41
  ) => Promise<BtwSettings>;
42
42
  }
43
43
 
44
- export type BtwCommandMenuResult = "start" | "closed" | { kind: "resume"; threadId: string };
44
+ export type BtwCommandMenuResult =
45
+ | "start"
46
+ | "tree"
47
+ | "closed"
48
+ | { kind: "resume"; threadId: string };
45
49
 
46
50
  type BtwMenuScreen = "main" | "resume" | "settings" | "invalid";
47
- type BtwMenuAction = "start" | "resume" | "set-thinking" | "set-remember";
51
+ type BtwMenuAction = "start" | "start-tree" | "resume" | "set-thinking" | "set-remember";
48
52
  const SAME_AS_MAIN_THREAD = "Same as main thread";
49
53
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
50
54
 
@@ -72,6 +76,7 @@ export async function showBtwCommandMenu(
72
76
  const displaySettingsPath = sanitizeSingleLine(settingsPath);
73
77
  const resumeThreads = options.resumeThreads ?? [];
74
78
  let startSelected = false;
79
+ let treeSelected = false;
75
80
  let resumedThreadId: string | undefined;
76
81
 
77
82
  const loadState = async (): Promise<BtwMenuState> => {
@@ -114,6 +119,12 @@ export async function showBtwCommandMenu(
114
119
  description: "Open an empty side thread",
115
120
  action: "start",
116
121
  },
122
+ {
123
+ id: "start-tree",
124
+ label: "Start from main thread tree…",
125
+ description: "Choose context without switching the main branch",
126
+ action: "start-tree",
127
+ },
117
128
  ...(resumeThreads.length > 0
118
129
  ? [
119
130
  {
@@ -184,6 +195,10 @@ export async function showBtwCommandMenu(
184
195
  startSelected = true;
185
196
  return { kind: "close" };
186
197
  },
198
+ "start-tree": async () => {
199
+ treeSelected = true;
200
+ return { kind: "close" };
201
+ },
187
202
  resume: async ({ itemId }: { itemId: string }) => {
188
203
  if (!resumeThreads.some((thread) => thread.id === itemId)) {
189
204
  return { kind: "rejected" } as const;
@@ -233,9 +248,37 @@ export async function showBtwCommandMenu(
233
248
  );
234
249
  if (result.kind !== "closed" || result.reason !== "close") return "closed";
235
250
  if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
251
+ if (treeSelected) return "tree";
236
252
  return startSelected ? "start" : "closed";
237
253
  }
238
254
 
255
+ export async function showBtwCustomPreservingEditor<T>(
256
+ ctx: ExtensionCommandContext,
257
+ factory: BtwCustomFactory<T>,
258
+ ): Promise<T | undefined> {
259
+ let liveEditorText = ctx.ui.getEditorText();
260
+ let completed = false;
261
+ const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) =>
262
+ factory(tui, theme, keybindings, (value) => {
263
+ try {
264
+ liveEditorText = ctx.ui.getEditorText();
265
+ } catch {
266
+ // Keep completion finite if session replacement invalidates the editor context.
267
+ }
268
+ completed = true;
269
+ done(value);
270
+ }),
271
+ );
272
+ if (completed) {
273
+ try {
274
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
275
+ } catch {
276
+ // A replaced context owns a different editor and must not receive stale restoration.
277
+ }
278
+ }
279
+ return result;
280
+ }
281
+
239
282
  export async function runBtwMenuPreservingEditor(
240
283
  ctx: ExtensionCommandContext,
241
284
  run: (menuContext: MenuContext) => Promise<RunMenuResult>,