@narumitw/pi-btw 0.49.7 → 0.51.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,11 +8,12 @@ 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 a side thread or changing pi-btw settings.
12
- - Keeps `/btw <question>` as a direct fast path.
11
+ - Adds a `/btw` menu for starting or resuming an in-memory side thread or changing pi-btw settings.
12
+ - Keeps `/btw <question>` as a direct fast path that always starts a fresh side thread.
13
13
  - Answers side questions in a dedicated, scrollable full-screen UI.
14
14
  - Keeps mouse-drag copying stable while the main agent continues running in the background.
15
15
  - Supports follow-up questions in the same ephemeral side thread.
16
+ - Resumes any non-empty side thread retained by the current Pi session, listed by its first question.
16
17
  - Queues Pi-style `Steering` questions while an answer is running and processes them one at a time.
17
18
  - Optionally brings the latest answer, a question-to-end suffix, an exact line range, or the entire side thread into the main editor.
18
19
  - Uses the current session branch as context.
@@ -57,10 +58,15 @@ Examples:
57
58
  /btw is this API name idiomatic?
58
59
  ```
59
60
 
60
- Running `/btw` alone opens a two-row menu. **Start side thread** is selected first, so pressing
61
- `Enter` opens an empty ephemeral side thread; **Settings** changes the starting thinking level
62
- and whether shortcut changes are remembered. `/btw <question>` bypasses this menu, and its answer
63
- opens above the side-thread editor. The side thread uses a dedicated full-screen terminal view.
61
+ Running `/btw` alone opens a menu with **Start side thread** selected first.
62
+ When the current Pi session has non-empty side threads in memory, **Resume side thread** opens a bounded searchable choice list.
63
+ 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.
65
+ 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
+ Opening and closing a thread without a new result does not reorder it.
67
+ `/btw <question>` bypasses this menu and always starts a fresh side thread.
68
+ Its answer opens above the side-thread editor.
69
+ The side thread uses a dedicated full-screen terminal view.
64
70
  The main agent continues running in the background, but its screen rendering stays suspended until
65
71
  `/btw` closes, so new main-thread output cannot move a mouse selection inside the side thread.
66
72
  Drag the primary mouse button across side-thread text to select and copy it through Pi's terminal
@@ -83,8 +89,9 @@ submission order and answered one at a time after the active response completes.
83
89
  A queued question uses the side thread's thinking level when its turn begins.
84
90
  A failed active response is shown in the transcript and does not discard later steering questions.
85
91
  The footer shows `PgUp`/`PgDn` only when history can scroll; press `Ctrl+C` to cancel the active
86
- response and discard the ephemeral side-thread draft and steering queue.
87
- Steering remains entirely inside pi-btw and never appends to the main conversation or editor.
92
+ response and discard the ephemeral side-thread draft and steering queue. Completed questions,
93
+ answers, and visible errors remain available through Resume until the current extension instance
94
+ ends. Steering remains entirely inside pi-btw and never appends to the main conversation or editor.
88
95
 
89
96
  After at least one successful answer, press `Ctrl+R` to bring selected context to the main
90
97
  editor. The scope menu shows the size of the latest question and answer and the entire side
@@ -108,9 +115,11 @@ into Pi's main editor. It never sends the draft automatically. If the main edito
108
115
  draft, append is the recommended default. Replace is labeled as destructive and requires a second
109
116
  confirmation; Cancel returns to the side thread without changing either draft. Concurrent editor
110
117
  updates made while these menus are open are preserved. A success message reports whether context
111
- was loaded, appended, or replaced and its approximate size. Without an explicit bring-to-main
112
- action, closing `/btw`, reloading Pi, or switching sessions still discards the side thread without
113
- adding it to the main conversation.
118
+ was loaded, appended, or replaced and its approximate size.
119
+ Without an explicit bring-to-main action, closing `/btw` never adds the side thread to the main
120
+ conversation. Non-empty threads remain only in memory for Resume within the current Pi session.
121
+ `/new`, Pi `/resume`, `/reload`, extension replacement, and process restart discard every retained
122
+ thread. Unsent drafts, steering queues, interrupted answers, and model credentials are never retained.
114
123
 
115
124
  ## ⚙️ Model and thinking level
116
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.49.7",
3
+ "version": "0.51.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.49.1"
46
+ "@narumitw/pi-tui-kit": "^0.54.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@earendil-works/pi-ai": "*",
package/src/btw.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  import { type RunBtwFullscreen, runBtwFullscreen } from "./fullscreen-ui.js";
29
29
  import {
30
30
  type BtwCommandMenuResult,
31
+ type BtwResumeThreadSummary,
31
32
  runBtwMenuPreservingEditor,
32
33
  showBtwCommandMenu,
33
34
  } from "./menu.js";
@@ -107,6 +108,15 @@ export interface ResolvedBtwModel {
107
108
  auth: SideQuestionAuth;
108
109
  }
109
110
 
111
+ export interface BtwThreadState {
112
+ id: string;
113
+ title?: string;
114
+ thread: SideThread;
115
+ thinkingLevel: BtwThinkingLevel;
116
+ createdAt: number;
117
+ updatedAt: number;
118
+ }
119
+
110
120
  export async function resolveBtwModel({
111
121
  settings,
112
122
  currentModel,
@@ -210,6 +220,7 @@ export interface BtwExtensionDependencies {
210
220
  showCommandMenu?: (
211
221
  pi: ExtensionAPI,
212
222
  ctx: ExtensionCommandContext,
223
+ resumeThreads: readonly BtwResumeThreadSummary[],
213
224
  ) => Promise<BtwCommandMenuResult>;
214
225
  loadSettings?: typeof loadSettingsForCommand;
215
226
  resolveModel?: typeof resolveBtwModelWithLoader;
@@ -223,6 +234,21 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
223
234
  const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
224
235
  const runThread = dependencies.runThread ?? runBtwThread;
225
236
  const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
237
+ // Pi creates a fresh extension instance after session replacement or reload.
238
+ const resumableThreads = new Map<string, BtwThreadState>();
239
+ let nextThreadNumber = 1;
240
+ const listResumeThreads = (): BtwResumeThreadSummary[] =>
241
+ [...resumableThreads.values()]
242
+ .reverse()
243
+ .filter((state) => state.thread.turns.length > 0 && state.title)
244
+ .sort(
245
+ (first, second) => second.updatedAt - first.updatedAt || second.createdAt - first.createdAt,
246
+ )
247
+ .map((state) => ({
248
+ id: state.id,
249
+ title: state.title ?? "Untitled side thread",
250
+ questionCount: state.thread.turns.length,
251
+ }));
226
252
  pi.registerCommand("btw", {
227
253
  description: "Ask a quick side question without adding it to the main conversation",
228
254
  handler: async (args, ctx) => {
@@ -231,7 +257,12 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
231
257
  ctx.ui.notify("/btw requires interactive TUI mode", "error");
232
258
  return;
233
259
  }
234
- if (!question && (await showCommandMenu(pi, ctx)) !== "start") return;
260
+
261
+ let menuResult: BtwCommandMenuResult = "start";
262
+ if (!question) {
263
+ menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
264
+ if (menuResult === "closed") return;
265
+ }
235
266
 
236
267
  const settings = await loadSettings(ctx);
237
268
  const resolution = await resolveModel(settings, ctx);
@@ -244,15 +275,46 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
244
275
  return;
245
276
  }
246
277
 
247
- await runFullscreen(ctx, (fullscreenCtx) =>
248
- runThread({
249
- initialQuestion: question || undefined,
250
- selected: resolution.selected,
251
- thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
252
- rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
253
- ctx: fullscreenCtx,
254
- }),
255
- );
278
+ let state =
279
+ typeof menuResult === "object" ? resumableThreads.get(menuResult.threadId) : undefined;
280
+ if (typeof menuResult === "object" && !state) {
281
+ notifySafely(ctx, "The selected /btw side thread is no longer available", "warning");
282
+ return;
283
+ }
284
+ const startingTurnCount = state?.thread.turns.length ?? 0;
285
+
286
+ try {
287
+ await runFullscreen(ctx, (fullscreenCtx) => {
288
+ if (!state) {
289
+ const createdAt = Date.now();
290
+ state = {
291
+ id: `btw-${nextThreadNumber}`,
292
+ thread: createSideThread(
293
+ buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
294
+ ),
295
+ thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
296
+ createdAt,
297
+ updatedAt: createdAt,
298
+ };
299
+ nextThreadNumber += 1;
300
+ }
301
+ return runThread({
302
+ initialQuestion: question || undefined,
303
+ selected: resolution.selected,
304
+ thinkingLevel: state.thinkingLevel,
305
+ rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
306
+ state,
307
+ ctx: fullscreenCtx,
308
+ });
309
+ });
310
+ } finally {
311
+ if (state?.title && state.thread.turns.length > 0) {
312
+ if (state.thread.turns.length > startingTurnCount) {
313
+ resumableThreads.delete(state.id);
314
+ }
315
+ resumableThreads.set(state.id, state);
316
+ }
317
+ }
256
318
  },
257
319
  });
258
320
  }
@@ -260,6 +322,7 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
260
322
  async function showCommandMenuForBtw(
261
323
  pi: ExtensionAPI,
262
324
  ctx: ExtensionCommandContext,
325
+ resumeThreads: readonly BtwResumeThreadSummary[],
263
326
  ): Promise<BtwCommandMenuResult> {
264
327
  const currentModel = ctx.model;
265
328
  const availableModels = ctx.modelRegistry.getAll();
@@ -276,6 +339,7 @@ async function showCommandMenuForBtw(
276
339
  return showBtwCommandMenu(ctx, {
277
340
  currentThinkingLevel,
278
341
  availableThinkingLevels: model ? getSupportedThinkingLevels(model) : BTW_THINKING_LEVELS,
342
+ resumeThreads,
279
343
  });
280
344
  }
281
345
 
@@ -335,6 +399,7 @@ interface RunBtwThreadDependencies {
335
399
  chooseBringToMain?: typeof chooseBringToMain;
336
400
  deliverBringToMain?: typeof loadBringToMainDraft;
337
401
  persistThinkingLevel?: (level: BtwThinkingLevel) => Promise<unknown>;
402
+ now?: () => number;
338
403
  }
339
404
 
340
405
  export type BtwThreadResult = { kind: "closed" };
@@ -365,6 +430,7 @@ interface RunBtwThreadOptions {
365
430
  thinkingLevel: BtwThinkingLevel;
366
431
  rememberThinkingLevelChanges?: boolean;
367
432
  settingsPath?: string;
433
+ state?: BtwThreadState;
368
434
  ctx: ExtensionCommandContext;
369
435
  dependencies?: RunBtwThreadDependencies;
370
436
  }
@@ -375,6 +441,7 @@ export async function runBtwThread({
375
441
  thinkingLevel,
376
442
  rememberThinkingLevelChanges = false,
377
443
  settingsPath,
444
+ state,
378
445
  ctx,
379
446
  dependencies = {},
380
447
  }: RunBtwThreadOptions): Promise<BtwThreadResult> {
@@ -385,11 +452,17 @@ export async function runBtwThread({
385
452
  const persistThinkingLevel =
386
453
  dependencies.persistThinkingLevel ??
387
454
  ((level: BtwThinkingLevel) => updateBtwSettings({ thinkingLevel: level }, { settingsPath }));
388
- const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
455
+ const now = dependencies.now ?? Date.now;
456
+ const thread =
457
+ state?.thread ?? createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
389
458
  const thinkingLevels = getSupportedThinkingLevels(selected.model);
390
459
  const pendingWrites = new Set<Promise<void>>();
391
460
  const steeringQuestions: string[] = [];
392
- let activeThinkingLevel = clampThinkingLevel(selected.model, thinkingLevel);
461
+ let activeThinkingLevel = clampThinkingLevel(
462
+ selected.model,
463
+ state?.thinkingLevel ?? thinkingLevel,
464
+ );
465
+ if (state) state.thinkingLevel = activeThinkingLevel;
393
466
  let pendingQuestion = initialQuestion;
394
467
  let composerDraft: string | undefined;
395
468
  const createThinkingControl = (): BtwThreadThinkingControl => ({
@@ -398,6 +471,7 @@ export async function runBtwThread({
398
471
  onChange: (level) => {
399
472
  if (!thinkingLevels.includes(level)) return;
400
473
  activeThinkingLevel = level;
474
+ if (state) state.thinkingLevel = level;
401
475
  if (!rememberThinkingLevelChanges) return;
402
476
  let write!: Promise<void>;
403
477
  write = Promise.resolve()
@@ -458,6 +532,10 @@ export async function runBtwThread({
458
532
  answer: result.message,
459
533
  });
460
534
  }
535
+ if (state) {
536
+ state.title ||= sanitizeSingleLine(pendingQuestion) || "Untitled side thread";
537
+ state.updatedAt = now();
538
+ }
461
539
 
462
540
  pendingQuestion = steeringQuestions.shift();
463
541
  }
package/src/menu.ts CHANGED
@@ -22,9 +22,16 @@ interface BtwMenuState {
22
22
  reason?: string;
23
23
  }
24
24
 
25
+ export interface BtwResumeThreadSummary {
26
+ id: string;
27
+ title: string;
28
+ questionCount: number;
29
+ }
30
+
25
31
  export interface ShowBtwCommandMenuOptions {
26
32
  currentThinkingLevel: BtwThinkingLevel;
27
33
  availableThinkingLevels: readonly BtwThinkingLevel[];
34
+ resumeThreads?: readonly BtwResumeThreadSummary[];
28
35
  settingsPath?: string;
29
36
  readSettings?: typeof readBtwSettings;
30
37
  updateSettings?: (
@@ -33,10 +40,10 @@ export interface ShowBtwCommandMenuOptions {
33
40
  ) => Promise<BtwSettings>;
34
41
  }
35
42
 
36
- export type BtwCommandMenuResult = "start" | "closed";
43
+ export type BtwCommandMenuResult = "start" | "closed" | { kind: "resume"; threadId: string };
37
44
 
38
- type BtwMenuScreen = "main" | "settings" | "invalid";
39
- type BtwMenuAction = "start" | "set-thinking" | "set-remember";
45
+ type BtwMenuScreen = "main" | "resume" | "settings" | "invalid";
46
+ type BtwMenuAction = "start" | "resume" | "set-thinking" | "set-remember";
40
47
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
41
48
 
42
49
  type BtwCustomFactory<T> = (
@@ -61,7 +68,9 @@ export async function showBtwCommandMenu(
61
68
  ? [...options.availableThinkingLevels]
62
69
  : (["off"] satisfies BtwThinkingLevel[]);
63
70
  const displaySettingsPath = sanitizeSingleLine(settingsPath);
71
+ const resumeThreads = options.resumeThreads ?? [];
64
72
  let startSelected = false;
73
+ let resumedThreadId: string | undefined;
65
74
 
66
75
  const loadState = async (): Promise<BtwMenuState> => {
67
76
  const loaded = await readSettings(settingsPath);
@@ -89,6 +98,16 @@ export async function showBtwCommandMenu(
89
98
  description: "Open an empty side thread",
90
99
  action: "start",
91
100
  },
101
+ ...(resumeThreads.length > 0
102
+ ? [
103
+ {
104
+ id: "resume" as const,
105
+ label: "Resume side thread",
106
+ description: "Continue an in-memory side thread",
107
+ to: "resume" as const,
108
+ },
109
+ ]
110
+ : []),
92
111
  {
93
112
  id: "settings",
94
113
  label: "Settings",
@@ -98,6 +117,19 @@ export async function showBtwCommandMenu(
98
117
  ],
99
118
  hint: "close",
100
119
  }),
120
+ resume: () => ({
121
+ kind: "choice",
122
+ title: "Resume BTW side thread",
123
+ enableSearch: true,
124
+ items: resumeThreads.map((thread) => ({
125
+ id: thread.id,
126
+ label: thread.title,
127
+ description: `${thread.questionCount} ${thread.questionCount === 1 ? "question" : "questions"}`,
128
+ })),
129
+ action: "resume",
130
+ viewportSize: 10,
131
+ hint: "back",
132
+ }),
101
133
  settings: ({ state }) => ({
102
134
  kind: "settings",
103
135
  title: "Pi BTW Settings",
@@ -136,6 +168,13 @@ export async function showBtwCommandMenu(
136
168
  startSelected = true;
137
169
  return { kind: "close" };
138
170
  },
171
+ resume: async ({ itemId }: { itemId: string }) => {
172
+ if (!resumeThreads.some((thread) => thread.id === itemId)) {
173
+ return { kind: "rejected" } as const;
174
+ }
175
+ resumedThreadId = itemId;
176
+ return { kind: "close" } as const;
177
+ },
139
178
  "set-thinking": async ({ value, signal }) => {
140
179
  if (!value || !levels.includes(value as BtwThinkingLevel)) return { kind: "rejected" };
141
180
  try {
@@ -172,9 +211,9 @@ export async function showBtwCommandMenu(
172
211
  const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
173
212
  runMenu(menuContext, menu, { getState: loadState }),
174
213
  );
175
- return startSelected && result.kind === "closed" && result.reason === "close"
176
- ? "start"
177
- : "closed";
214
+ if (result.kind !== "closed" || result.reason !== "close") return "closed";
215
+ if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
216
+ return startSelected ? "start" : "closed";
178
217
  }
179
218
 
180
219
  export async function runBtwMenuPreservingEditor(