@narumitw/pi-btw 0.51.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.
@@ -18,7 +19,7 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
18
19
  - Optionally brings the latest answer, a question-to-end suffix, an exact line range, or the entire side thread into the main editor.
19
20
  - Uses the current session branch as context.
20
21
  - Uses Pi's current model or an independent model selected in `pi-btw.json`.
21
- - Uses a pi-btw thinking level that can be changed with Pi's configured thinking shortcut and remembered for next time.
22
+ - Uses a pi-btw thinking level that can either start from the main thread or use a fixed remembered value.
22
23
  - Does not append the side question or answer to the main conversation.
23
24
  - Works as an independently installable npm Pi extension package.
24
25
 
@@ -59,9 +60,14 @@ 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
- **Settings** changes the starting thinking level and whether shortcut changes are remembered.
70
+ **Settings** changes the starting thinking level and whether shortcut changes for fixed levels are remembered.
65
71
  Each Resume row keeps the first question as its fixed title, shows its question count, and the list is ordered by the newest recorded answer or visible error.
66
72
  Opening and closing a thread without a new result does not reorder it.
67
73
  `/btw <question>` bypasses this menu and always starts a fresh side thread.
@@ -79,9 +85,11 @@ Previous side questions and answers remain available to the model and visible fo
79
85
  invocation. The side-thread header shows its current thinking level. Press Pi's configured
80
86
  `app.thinking.cycle` shortcut (`Shift+Tab` by default) in the composer to cycle the levels
81
87
  supported by the side-thread model; every later question uses the displayed level until it is
82
- changed again. By default, each shortcut change is also written to `pi-btw.json` for the next
83
- invocation. Turn **Remember thinking level changes** off in Settings to keep changes local to the
84
- current side thread. Neither path changes the main session's thinking level.
88
+ changed again. When a fixed thinking level is selected, each shortcut change is also written to
89
+ `pi-btw.json` for the next invocation by default. Turn **Remember thinking level changes** off in
90
+ Settings to keep fixed-level changes local to the current side thread. When **Same as main thread** is
91
+ selected, shortcut changes are always local to the current side thread. Neither path changes the main
92
+ session's thinking level.
85
93
  While a response is running, the transcript and composer remain visible above an `Answering…`
86
94
  status.
87
95
  Type another question and press `Enter` to queue it as `Steering`; queued questions are shown in
@@ -148,18 +156,23 @@ cannot be found or authenticated, pi-btw warns and falls back to the current ses
148
156
  If neither model is available, `/btw` reports an error and stops. This selection affects only
149
157
  `/btw`; it does not change the main session model.
150
158
 
151
- Pi calls its reasoning setting the **thinking level**. `thinkingLevel` sets pi-btw's starting
152
- level; accepted values are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. When the
153
- field is absent for backward compatibility, the next invocation starts from the current session
154
- level. The initial value and shortcut cycle are clamped to the selected side model's capabilities
155
- using Pi's model rules. Pi-btw does not read, write, or change the main session's
156
- `defaultThinkingLevel`.
159
+ Pi calls its reasoning setting the **thinking level**. In Settings, choose **Same as main thread**
160
+ to start each new side thread from the main thread's current thinking level. This is stored by
161
+ omitting `thinkingLevel` from `pi-btw.json`.
157
162
 
158
- `rememberThinkingLevelChanges` controls only persistence and defaults to `true` when omitted. A
159
- side-thread shortcut always changes that side thread immediately. When remembering is on, the
160
- concrete level is written for the next invocation; when off, `pi-btw.json` stays unchanged. If a
161
- shortcut write fails, the local change remains active and pi-btw warns that it was not remembered.
162
- A failed Settings-screen save instead restores the previous displayed value.
163
+ Set `thinkingLevel` only when you want a fixed pi-btw starting level. Accepted fixed values are
164
+ `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. The initial value and shortcut cycle
165
+ are clamped to the selected side model's capabilities using Pi's model rules. Resumed side threads
166
+ keep their own local thinking level instead of re-syncing with the main thread. Pi-btw does not read,
167
+ write, or change the main session's `defaultThinkingLevel`.
168
+
169
+ `rememberThinkingLevelChanges` controls only persistence for fixed thinking levels and defaults to
170
+ `true` when omitted. A side-thread shortcut always changes that side thread immediately. When a fixed
171
+ thinking level is selected and remembering is on, the concrete level is written for the next
172
+ invocation; when off, `pi-btw.json` stays unchanged. When **Same as main thread** is selected,
173
+ shortcut changes stay local even when remembering is on. If a shortcut write fails, the local change
174
+ remains active and pi-btw warns that it was not remembered. A failed Settings-screen save instead
175
+ restores the previous displayed value.
163
176
 
164
177
  A missing settings file is a side-effect-free read: pi-btw creates it only after a Settings change
165
178
  or a remembered shortcut change. Saves are ordered within the Pi process and published atomically
@@ -181,6 +194,7 @@ packages/pi-btw/
181
194
  │ ├── index.ts
182
195
  │ ├── btw.ts
183
196
  │ ├── bring-to-main.ts
197
+ │ ├── main-tree-picker.ts
184
198
  │ ├── menu.ts
185
199
  │ ├── settings.ts
186
200
  │ ├── side-thread.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.51.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",
@@ -31,10 +31,10 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "devDependencies": {
34
- "@biomejs/biome": "2.5.7",
35
- "@earendil-works/pi-ai": "0.84.1",
36
- "@earendil-works/pi-coding-agent": "0.84.1",
37
- "@earendil-works/pi-tui": "0.84.1",
34
+ "@biomejs/biome": "2.5.8",
35
+ "@earendil-works/pi-ai": "0.84.2",
36
+ "@earendil-works/pi-coding-agent": "0.84.2",
37
+ "@earendil-works/pi-tui": "0.84.2",
38
38
  "typescript": "7.0.2"
39
39
  },
40
40
  "repository": {
@@ -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,
@@ -194,7 +193,7 @@ export async function loadBtwThinkingLevel(
194
193
 
195
194
  options.warn?.(
196
195
  sanitizeSingleLine(
197
- `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.`,
196
+ `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id", omitted thinkingLevel for Same as main thread or thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}", and boolean rememberThinkingLevelChanges. Using current Pi thinking level.`,
198
197
  ),
199
198
  );
200
199
  return currentThinkingLevel;
@@ -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,12 +260,41 @@ 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);
297
+ const sameAsMainThinkingLevel = settings.thinkingLevel === undefined;
268
298
  const resolution = await resolveModel(settings, ctx);
269
299
  if (resolution.kind === "cancelled") {
270
300
  notifySafely(ctx, "Cancelled", "info");
@@ -290,7 +320,8 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
290
320
  state = {
291
321
  id: `btw-${nextThreadNumber}`,
292
322
  thread: createSideThread(
293
- buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
323
+ selectedConversationContext ??
324
+ buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
294
325
  ),
295
326
  thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
296
327
  createdAt,
@@ -302,7 +333,8 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
302
333
  initialQuestion: question || undefined,
303
334
  selected: resolution.selected,
304
335
  thinkingLevel: state.thinkingLevel,
305
- rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
336
+ rememberThinkingLevelChanges:
337
+ !sameAsMainThinkingLevel && effectiveRememberThinkingLevelChanges(settings),
306
338
  state,
307
339
  ctx: fullscreenCtx,
308
340
  });
@@ -544,40 +576,6 @@ export async function runBtwThread({
544
576
  }
545
577
  }
546
578
 
547
- type BtwCustomFactory<T> = (
548
- tui: TUI,
549
- theme: Theme,
550
- keybindings: KeybindingsManager,
551
- done: (result: T) => void,
552
- ) => Component;
553
-
554
- async function showBtwCustomPreservingEditor<T>(
555
- ctx: ExtensionCommandContext,
556
- factory: BtwCustomFactory<T>,
557
- ): Promise<T | undefined> {
558
- let liveEditorText = ctx.ui.getEditorText();
559
- let completed = false;
560
- const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) =>
561
- factory(tui, theme, keybindings, (value) => {
562
- try {
563
- liveEditorText = ctx.ui.getEditorText();
564
- } catch {
565
- // Keep completion finite if session replacement invalidates the editor context.
566
- }
567
- completed = true;
568
- done(value);
569
- }),
570
- );
571
- if (completed) {
572
- try {
573
- if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
574
- } catch {
575
- // A replaced context owns a different editor and must not receive stale restoration.
576
- }
577
- }
578
- return result;
579
- }
580
-
581
579
  interface ChooseBringToMainDependencies {
582
580
  showMenu?: typeof showBtwMenu;
583
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
@@ -7,6 +7,7 @@ import type { Component, TUI } from "@earendil-works/pi-tui";
7
7
  import type { MenuContext, RunMenuResult } from "@narumitw/pi-tui-kit";
8
8
  import {
9
9
  type BtwSettings,
10
+ type BtwSettingsPatch,
10
11
  btwSettingsPath,
11
12
  effectiveRememberThinkingLevelChanges,
12
13
  readBtwSettings,
@@ -35,15 +36,20 @@ export interface ShowBtwCommandMenuOptions {
35
36
  settingsPath?: string;
36
37
  readSettings?: typeof readBtwSettings;
37
38
  updateSettings?: (
38
- patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
39
+ patch: BtwSettingsPatch,
39
40
  options: UpdateBtwSettingsOptions,
40
41
  ) => Promise<BtwSettings>;
41
42
  }
42
43
 
43
- export type BtwCommandMenuResult = "start" | "closed" | { kind: "resume"; threadId: string };
44
+ export type BtwCommandMenuResult =
45
+ | "start"
46
+ | "tree"
47
+ | "closed"
48
+ | { kind: "resume"; threadId: string };
44
49
 
45
50
  type BtwMenuScreen = "main" | "resume" | "settings" | "invalid";
46
- type BtwMenuAction = "start" | "resume" | "set-thinking" | "set-remember";
51
+ type BtwMenuAction = "start" | "start-tree" | "resume" | "set-thinking" | "set-remember";
52
+ const SAME_AS_MAIN_THREAD = "Same as main thread";
47
53
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
48
54
 
49
55
  type BtwCustomFactory<T> = (
@@ -70,6 +76,7 @@ export async function showBtwCommandMenu(
70
76
  const displaySettingsPath = sanitizeSingleLine(settingsPath);
71
77
  const resumeThreads = options.resumeThreads ?? [];
72
78
  let startSelected = false;
79
+ let treeSelected = false;
73
80
  let resumedThreadId: string | undefined;
74
81
 
75
82
  const loadState = async (): Promise<BtwMenuState> => {
@@ -79,8 +86,22 @@ export async function showBtwCommandMenu(
79
86
  }
80
87
  return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
81
88
  };
82
- const displayThinkingLevel = (settings: BtwSettings): BtwThinkingLevel =>
83
- clampToAvailableThinkingLevel(settings.thinkingLevel ?? options.currentThinkingLevel, levels);
89
+ const currentMainThinkingLevel = clampToAvailableThinkingLevel(
90
+ options.currentThinkingLevel,
91
+ levels,
92
+ );
93
+ const displayThinkingLevel = (settings: BtwSettings): string =>
94
+ settings.thinkingLevel === undefined
95
+ ? SAME_AS_MAIN_THREAD
96
+ : clampToAvailableThinkingLevel(settings.thinkingLevel, levels);
97
+ const displayThinkingSummary = (settings: BtwSettings): string =>
98
+ settings.thinkingLevel === undefined
99
+ ? `${SAME_AS_MAIN_THREAD} (currently ${currentMainThinkingLevel})`
100
+ : displayThinkingLevel(settings);
101
+ const displayRememberSummary = (settings: BtwSettings): string => {
102
+ const value = effectiveRememberThinkingLevelChanges(settings) ? "On" : "Off";
103
+ return settings.thinkingLevel === undefined ? `${value} (fixed levels only)` : value;
104
+ };
84
105
 
85
106
  const menu = defineMenu<BtwMenuState, BtwMenuScreen, BtwMenuAction, MenuContext>({
86
107
  start: "main",
@@ -89,7 +110,7 @@ export async function showBtwCommandMenu(
89
110
  kind: "actions",
90
111
  title: "Pi BTW",
91
112
  lines: [
92
- `Thinking: ${displayThinkingLevel(state.settings)} · Remember changes: ${effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off"}`,
113
+ `Thinking: ${displayThinkingSummary(state.settings)} · Remember changes: ${displayRememberSummary(state.settings)}`,
93
114
  ],
94
115
  items: [
95
116
  {
@@ -98,6 +119,12 @@ export async function showBtwCommandMenu(
98
119
  description: "Open an empty side thread",
99
120
  action: "start",
100
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
+ },
101
128
  ...(resumeThreads.length > 0
102
129
  ? [
103
130
  {
@@ -111,7 +138,7 @@ export async function showBtwCommandMenu(
111
138
  {
112
139
  id: "settings",
113
140
  label: "Settings",
114
- description: "Choose pi-btw thinking and whether shortcut changes are remembered",
141
+ description: "Choose pi-btw thinking level and fixed-level shortcut memory",
115
142
  to: state.kind === "invalid" ? "invalid" : "settings",
116
143
  },
117
144
  ],
@@ -138,15 +165,15 @@ export async function showBtwCommandMenu(
138
165
  {
139
166
  id: "thinkingLevel",
140
167
  label: "Thinking level",
141
- description: "Set the starting level for future pi-btw side threads.",
168
+ description: `Set the starting level for future pi-btw side threads. Currently ${currentMainThinkingLevel}.`,
142
169
  currentValue: displayThinkingLevel(state.settings),
143
- values: levels,
170
+ values: [SAME_AS_MAIN_THREAD, ...levels],
144
171
  action: "set-thinking",
145
172
  },
146
173
  {
147
174
  id: "rememberThinkingLevelChanges",
148
175
  label: "Remember thinking level changes",
149
- description: "Save side-thread shortcut changes to pi-btw.json for next time.",
176
+ description: "Save shortcut changes for fixed thinking levels to pi-btw.json.",
150
177
  currentValue: effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off",
151
178
  values: ["On", "Off"],
152
179
  action: "set-remember",
@@ -168,6 +195,10 @@ export async function showBtwCommandMenu(
168
195
  startSelected = true;
169
196
  return { kind: "close" };
170
197
  },
198
+ "start-tree": async () => {
199
+ treeSelected = true;
200
+ return { kind: "close" };
201
+ },
171
202
  resume: async ({ itemId }: { itemId: string }) => {
172
203
  if (!resumeThreads.some((thread) => thread.id === itemId)) {
173
204
  return { kind: "rejected" } as const;
@@ -176,12 +207,16 @@ export async function showBtwCommandMenu(
176
207
  return { kind: "close" } as const;
177
208
  },
178
209
  "set-thinking": async ({ value, signal }) => {
179
- if (!value || !levels.includes(value as BtwThinkingLevel)) return { kind: "rejected" };
210
+ if (!value) return { kind: "rejected" };
211
+ const patch =
212
+ value === SAME_AS_MAIN_THREAD
213
+ ? ({ thinkingLevel: undefined } satisfies BtwSettingsPatch)
214
+ : levels.includes(value as BtwThinkingLevel)
215
+ ? ({ thinkingLevel: value as BtwThinkingLevel } satisfies BtwSettingsPatch)
216
+ : undefined;
217
+ if (!patch) return { kind: "rejected" };
180
218
  try {
181
- await updateSettings(
182
- { thinkingLevel: value as BtwThinkingLevel },
183
- { settingsPath, signal },
184
- );
219
+ await updateSettings(patch, { settingsPath, signal });
185
220
  if (signal.aborted) return { kind: "rejected" };
186
221
  notifySafely(ctx, `Pi BTW thinking level: ${value}.`, "info");
187
222
  return { kind: "stay" };
@@ -213,9 +248,37 @@ export async function showBtwCommandMenu(
213
248
  );
214
249
  if (result.kind !== "closed" || result.reason !== "close") return "closed";
215
250
  if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
251
+ if (treeSelected) return "tree";
216
252
  return startSelected ? "start" : "closed";
217
253
  }
218
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
+
219
282
  export async function runBtwMenuPreservingEditor(
220
283
  ctx: ExtensionCommandContext,
221
284
  run: (menuContext: MenuContext) => Promise<RunMenuResult>,
package/src/settings.ts CHANGED
@@ -20,6 +20,11 @@ export type BtwSettingsLoadResult =
20
20
  | { kind: "invalid"; reason: string }
21
21
  | { kind: "loaded"; settings: BtwSettings };
22
22
 
23
+ export interface BtwSettingsPatch {
24
+ thinkingLevel?: BtwThinkingLevel;
25
+ rememberThinkingLevelChanges?: boolean;
26
+ }
27
+
23
28
  export interface UpdateBtwSettingsOptions {
24
29
  settingsPath?: string;
25
30
  signal?: AbortSignal;
@@ -77,14 +82,14 @@ export async function readBtwSettings(
77
82
  }
78
83
 
79
84
  export function updateBtwSettings(
80
- patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
85
+ patch: BtwSettingsPatch,
81
86
  options: UpdateBtwSettingsOptions = {},
82
87
  ): Promise<BtwSettings> {
83
88
  const settingsPath = options.settingsPath ?? btwSettingsPath();
84
89
  return enqueueMutation(settingsPath, async () => {
85
90
  options.signal?.throwIfAborted();
86
91
  const current = await readSettingsDocumentForUpdate(settingsPath);
87
- const updated: SettingsDocument = { ...current, ...patch };
92
+ const updated = applyBtwSettingsPatch(current, patch);
88
93
  const settings = normalizeBtwSettings(updated);
89
94
  if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
90
95
  await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
@@ -216,6 +221,21 @@ async function publishSettings(
216
221
  }
217
222
  }
218
223
 
224
+ function applyBtwSettingsPatch(
225
+ current: SettingsDocument,
226
+ patch: BtwSettingsPatch,
227
+ ): SettingsDocument {
228
+ const updated: SettingsDocument = { ...current };
229
+ if (Object.hasOwn(patch, "thinkingLevel")) {
230
+ if (patch.thinkingLevel === undefined) delete updated.thinkingLevel;
231
+ else updated.thinkingLevel = patch.thinkingLevel;
232
+ }
233
+ if (Object.hasOwn(patch, "rememberThinkingLevelChanges")) {
234
+ updated.rememberThinkingLevelChanges = patch.rememberThinkingLevelChanges;
235
+ }
236
+ return updated;
237
+ }
238
+
219
239
  function isSettingsDocument(value: unknown): value is SettingsDocument {
220
240
  return typeof value === "object" && value !== null && !Array.isArray(value);
221
241
  }