@narumitw/pi-btw 0.49.7 → 0.50.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 +19 -11
- package/package.json +1 -1
- package/src/btw.ts +90 -12
- package/src/menu.ts +44 -6
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
|
|
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,14 @@ Examples:
|
|
|
57
58
|
/btw is this API name idiomatic?
|
|
58
59
|
```
|
|
59
60
|
|
|
60
|
-
Running `/btw` alone opens a
|
|
61
|
-
|
|
62
|
-
and whether shortcut changes are remembered.
|
|
63
|
-
|
|
61
|
+
Running `/btw` alone opens a menu with **Start side thread** selected first. When the current Pi
|
|
62
|
+
session has non-empty side threads in memory, **Resume side thread** opens a bounded choice list;
|
|
63
|
+
**Settings** changes the starting thinking level and whether shortcut changes are remembered.
|
|
64
|
+
Each Resume row keeps the first question as its fixed title, shows its question count, and the list
|
|
65
|
+
is ordered by the newest recorded answer or visible error. Opening and closing a thread without a
|
|
66
|
+
new result does not reorder it. `/btw <question>` bypasses this menu and always starts a fresh side
|
|
67
|
+
thread. Its answer opens above the side-thread editor. The side thread uses a dedicated full-screen
|
|
68
|
+
terminal view.
|
|
64
69
|
The main agent continues running in the background, but its screen rendering stays suspended until
|
|
65
70
|
`/btw` closes, so new main-thread output cannot move a mouse selection inside the side thread.
|
|
66
71
|
Drag the primary mouse button across side-thread text to select and copy it through Pi's terminal
|
|
@@ -83,8 +88,9 @@ submission order and answered one at a time after the active response completes.
|
|
|
83
88
|
A queued question uses the side thread's thinking level when its turn begins.
|
|
84
89
|
A failed active response is shown in the transcript and does not discard later steering questions.
|
|
85
90
|
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
|
-
|
|
91
|
+
response and discard the ephemeral side-thread draft and steering queue. Completed questions,
|
|
92
|
+
answers, and visible errors remain available through Resume until the current extension instance
|
|
93
|
+
ends. Steering remains entirely inside pi-btw and never appends to the main conversation or editor.
|
|
88
94
|
|
|
89
95
|
After at least one successful answer, press `Ctrl+R` to bring selected context to the main
|
|
90
96
|
editor. The scope menu shows the size of the latest question and answer and the entire side
|
|
@@ -108,9 +114,11 @@ into Pi's main editor. It never sends the draft automatically. If the main edito
|
|
|
108
114
|
draft, append is the recommended default. Replace is labeled as destructive and requires a second
|
|
109
115
|
confirmation; Cancel returns to the side thread without changing either draft. Concurrent editor
|
|
110
116
|
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.
|
|
112
|
-
action, closing `/btw
|
|
113
|
-
|
|
117
|
+
was loaded, appended, or replaced and its approximate size.
|
|
118
|
+
Without an explicit bring-to-main action, closing `/btw` never adds the side thread to the main
|
|
119
|
+
conversation. Non-empty threads remain only in memory for Resume within the current Pi session.
|
|
120
|
+
`/new`, Pi `/resume`, `/reload`, extension replacement, and process restart discard every retained
|
|
121
|
+
thread. Unsent drafts, steering queues, interrupted answers, and model credentials are never retained.
|
|
114
122
|
|
|
115
123
|
## ⚙️ Model and thinking level
|
|
116
124
|
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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(
|
|
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,18 @@ export async function showBtwCommandMenu(
|
|
|
98
117
|
],
|
|
99
118
|
hint: "close",
|
|
100
119
|
}),
|
|
120
|
+
resume: () => ({
|
|
121
|
+
kind: "choice",
|
|
122
|
+
title: "Resume BTW side thread",
|
|
123
|
+
items: resumeThreads.map((thread) => ({
|
|
124
|
+
id: thread.id,
|
|
125
|
+
label: thread.title,
|
|
126
|
+
description: `${thread.questionCount} ${thread.questionCount === 1 ? "question" : "questions"}`,
|
|
127
|
+
})),
|
|
128
|
+
action: "resume",
|
|
129
|
+
viewportSize: 10,
|
|
130
|
+
hint: "back",
|
|
131
|
+
}),
|
|
101
132
|
settings: ({ state }) => ({
|
|
102
133
|
kind: "settings",
|
|
103
134
|
title: "Pi BTW Settings",
|
|
@@ -136,6 +167,13 @@ export async function showBtwCommandMenu(
|
|
|
136
167
|
startSelected = true;
|
|
137
168
|
return { kind: "close" };
|
|
138
169
|
},
|
|
170
|
+
resume: async ({ itemId }: { itemId: string }) => {
|
|
171
|
+
if (!resumeThreads.some((thread) => thread.id === itemId)) {
|
|
172
|
+
return { kind: "rejected" } as const;
|
|
173
|
+
}
|
|
174
|
+
resumedThreadId = itemId;
|
|
175
|
+
return { kind: "close" } as const;
|
|
176
|
+
},
|
|
139
177
|
"set-thinking": async ({ value, signal }) => {
|
|
140
178
|
if (!value || !levels.includes(value as BtwThinkingLevel)) return { kind: "rejected" };
|
|
141
179
|
try {
|
|
@@ -172,9 +210,9 @@ export async function showBtwCommandMenu(
|
|
|
172
210
|
const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
|
|
173
211
|
runMenu(menuContext, menu, { getState: loadState }),
|
|
174
212
|
);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
213
|
+
if (result.kind !== "closed" || result.reason !== "close") return "closed";
|
|
214
|
+
if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
|
|
215
|
+
return startSelected ? "start" : "closed";
|
|
178
216
|
}
|
|
179
217
|
|
|
180
218
|
export async function runBtwMenuPreservingEditor(
|