@narumitw/pi-btw 0.49.6 → 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 +36 -15
- package/package.json +4 -4
- package/src/btw.ts +138 -36
- package/src/fullscreen-ui.ts +339 -0
- package/src/menu.ts +44 -6
- package/src/side-thread.ts +32 -12
- package/src/transcript-pager.ts +157 -18
package/README.md
CHANGED
|
@@ -8,10 +8,13 @@ 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.
|
|
13
|
-
- Answers side questions in a
|
|
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
|
+
- Answers side questions in a dedicated, scrollable full-screen UI.
|
|
14
|
+
- Keeps mouse-drag copying stable while the main agent continues running in the background.
|
|
14
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.
|
|
17
|
+
- Queues Pi-style `Steering` questions while an answer is running and processes them one at a time.
|
|
15
18
|
- Optionally brings the latest answer, a question-to-end suffix, an exact line range, or the entire side thread into the main editor.
|
|
16
19
|
- Uses the current session branch as context.
|
|
17
20
|
- Uses Pi's current model or an independent model selected in `pi-btw.json`.
|
|
@@ -55,11 +58,20 @@ Examples:
|
|
|
55
58
|
/btw is this API name idiomatic?
|
|
56
59
|
```
|
|
57
60
|
|
|
58
|
-
Running `/btw` alone opens a
|
|
59
|
-
|
|
60
|
-
and whether shortcut changes are remembered.
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
69
|
+
The main agent continues running in the background, but its screen rendering stays suspended until
|
|
70
|
+
`/btw` closes, so new main-thread output cannot move a mouse selection inside the side thread.
|
|
71
|
+
Drag the primary mouse button across side-thread text to select and copy it through Pi's terminal
|
|
72
|
+
clipboard support. Returning from `/btw` redraws the main view with everything produced while it
|
|
73
|
+
was hidden. A compact `btw · side thread` header stays fixed above the content so the ephemeral
|
|
74
|
+
workspace remains recognizable while scrolling. Messages use Pi's normal
|
|
63
75
|
user and assistant presentation without numbered turns or role labels. Type each question and press
|
|
64
76
|
`Enter`; no follow-up shortcut is required.
|
|
65
77
|
Previous side questions and answers remain available to the model and visible for that
|
|
@@ -68,10 +80,17 @@ invocation. The side-thread header shows its current thinking level. Press Pi's
|
|
|
68
80
|
supported by the side-thread model; every later question uses the displayed level until it is
|
|
69
81
|
changed again. By default, each shortcut change is also written to `pi-btw.json` for the next
|
|
70
82
|
invocation. Turn **Remember thinking level changes** off in Settings to keep changes local to the
|
|
71
|
-
current side thread. Neither path changes the main session's thinking level.
|
|
72
|
-
running, the transcript
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
current side thread. Neither path changes the main session's thinking level.
|
|
84
|
+
While a response is running, the transcript and composer remain visible above an `Answering…`
|
|
85
|
+
status.
|
|
86
|
+
Type another question and press `Enter` to queue it as `Steering`; queued questions are shown in
|
|
87
|
+
submission order and answered one at a time after the active response completes.
|
|
88
|
+
A queued question uses the side thread's thinking level when its turn begins.
|
|
89
|
+
A failed active response is shown in the transcript and does not discard later steering questions.
|
|
90
|
+
The footer shows `PgUp`/`PgDn` only when history can scroll; press `Ctrl+C` to cancel the active
|
|
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.
|
|
75
94
|
|
|
76
95
|
After at least one successful answer, press `Ctrl+R` to bring selected context to the main
|
|
77
96
|
editor. The scope menu shows the size of the latest question and answer and the entire side
|
|
@@ -95,9 +114,11 @@ into Pi's main editor. It never sends the draft automatically. If the main edito
|
|
|
95
114
|
draft, append is the recommended default. Replace is labeled as destructive and requires a second
|
|
96
115
|
confirmation; Cancel returns to the side thread without changing either draft. Concurrent editor
|
|
97
116
|
updates made while these menus are open are preserved. A success message reports whether context
|
|
98
|
-
was loaded, appended, or replaced and its approximate size.
|
|
99
|
-
action, closing `/btw
|
|
100
|
-
|
|
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.
|
|
101
122
|
|
|
102
123
|
## ⚙️ Model and thinking level
|
|
103
124
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-btw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"description": "Pi extension that adds a /btw side-question command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@biomejs/biome": "2.5.7",
|
|
35
|
-
"@earendil-works/pi-ai": "0.84.
|
|
36
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
37
|
-
"@earendil-works/pi-tui": "0.84.
|
|
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",
|
|
38
38
|
"typescript": "7.0.2"
|
|
39
39
|
},
|
|
40
40
|
"repository": {
|
package/src/btw.ts
CHANGED
|
@@ -25,8 +25,10 @@ import {
|
|
|
25
25
|
getAnsweredTurns,
|
|
26
26
|
summarizeBringToMain,
|
|
27
27
|
} from "./bring-to-main.js";
|
|
28
|
+
import { type RunBtwFullscreen, runBtwFullscreen } from "./fullscreen-ui.js";
|
|
28
29
|
import {
|
|
29
30
|
type BtwCommandMenuResult,
|
|
31
|
+
type BtwResumeThreadSummary,
|
|
30
32
|
runBtwMenuPreservingEditor,
|
|
31
33
|
showBtwCommandMenu,
|
|
32
34
|
} from "./menu.js";
|
|
@@ -106,6 +108,15 @@ export interface ResolvedBtwModel {
|
|
|
106
108
|
auth: SideQuestionAuth;
|
|
107
109
|
}
|
|
108
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
|
+
|
|
109
120
|
export async function resolveBtwModel({
|
|
110
121
|
settings,
|
|
111
122
|
currentModel,
|
|
@@ -209,10 +220,12 @@ export interface BtwExtensionDependencies {
|
|
|
209
220
|
showCommandMenu?: (
|
|
210
221
|
pi: ExtensionAPI,
|
|
211
222
|
ctx: ExtensionCommandContext,
|
|
223
|
+
resumeThreads: readonly BtwResumeThreadSummary[],
|
|
212
224
|
) => Promise<BtwCommandMenuResult>;
|
|
213
225
|
loadSettings?: typeof loadSettingsForCommand;
|
|
214
226
|
resolveModel?: typeof resolveBtwModelWithLoader;
|
|
215
227
|
runThread?: typeof runBtwThread;
|
|
228
|
+
runFullscreen?: RunBtwFullscreen;
|
|
216
229
|
}
|
|
217
230
|
|
|
218
231
|
export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
|
|
@@ -220,6 +233,22 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
220
233
|
const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
|
|
221
234
|
const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
|
|
222
235
|
const runThread = dependencies.runThread ?? runBtwThread;
|
|
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
|
+
}));
|
|
223
252
|
pi.registerCommand("btw", {
|
|
224
253
|
description: "Ask a quick side question without adding it to the main conversation",
|
|
225
254
|
handler: async (args, ctx) => {
|
|
@@ -228,7 +257,12 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
228
257
|
ctx.ui.notify("/btw requires interactive TUI mode", "error");
|
|
229
258
|
return;
|
|
230
259
|
}
|
|
231
|
-
|
|
260
|
+
|
|
261
|
+
let menuResult: BtwCommandMenuResult = "start";
|
|
262
|
+
if (!question) {
|
|
263
|
+
menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
|
|
264
|
+
if (menuResult === "closed") return;
|
|
265
|
+
}
|
|
232
266
|
|
|
233
267
|
const settings = await loadSettings(ctx);
|
|
234
268
|
const resolution = await resolveModel(settings, ctx);
|
|
@@ -241,13 +275,46 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
241
275
|
return;
|
|
242
276
|
}
|
|
243
277
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
}
|
|
251
318
|
},
|
|
252
319
|
});
|
|
253
320
|
}
|
|
@@ -255,6 +322,7 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
255
322
|
async function showCommandMenuForBtw(
|
|
256
323
|
pi: ExtensionAPI,
|
|
257
324
|
ctx: ExtensionCommandContext,
|
|
325
|
+
resumeThreads: readonly BtwResumeThreadSummary[],
|
|
258
326
|
): Promise<BtwCommandMenuResult> {
|
|
259
327
|
const currentModel = ctx.model;
|
|
260
328
|
const availableModels = ctx.modelRegistry.getAll();
|
|
@@ -271,6 +339,7 @@ async function showCommandMenuForBtw(
|
|
|
271
339
|
return showBtwCommandMenu(ctx, {
|
|
272
340
|
currentThinkingLevel,
|
|
273
341
|
availableThinkingLevels: model ? getSupportedThinkingLevels(model) : BTW_THINKING_LEVELS,
|
|
342
|
+
resumeThreads,
|
|
274
343
|
});
|
|
275
344
|
}
|
|
276
345
|
|
|
@@ -330,12 +399,19 @@ interface RunBtwThreadDependencies {
|
|
|
330
399
|
chooseBringToMain?: typeof chooseBringToMain;
|
|
331
400
|
deliverBringToMain?: typeof loadBringToMainDraft;
|
|
332
401
|
persistThinkingLevel?: (level: BtwThinkingLevel) => Promise<unknown>;
|
|
402
|
+
now?: () => number;
|
|
333
403
|
}
|
|
334
404
|
|
|
335
405
|
export type BtwThreadResult = { kind: "closed" };
|
|
336
406
|
|
|
337
407
|
type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
|
|
338
408
|
|
|
409
|
+
interface BtwThreadSteeringControl {
|
|
410
|
+
questions: readonly string[];
|
|
411
|
+
submit: (question: string) => void;
|
|
412
|
+
thinking: BtwThreadThinkingControl;
|
|
413
|
+
}
|
|
414
|
+
|
|
339
415
|
type BtwBringToMainChoice =
|
|
340
416
|
| BtwThreadResult
|
|
341
417
|
| {
|
|
@@ -354,6 +430,7 @@ interface RunBtwThreadOptions {
|
|
|
354
430
|
thinkingLevel: BtwThinkingLevel;
|
|
355
431
|
rememberThinkingLevelChanges?: boolean;
|
|
356
432
|
settingsPath?: string;
|
|
433
|
+
state?: BtwThreadState;
|
|
357
434
|
ctx: ExtensionCommandContext;
|
|
358
435
|
dependencies?: RunBtwThreadDependencies;
|
|
359
436
|
}
|
|
@@ -364,6 +441,7 @@ export async function runBtwThread({
|
|
|
364
441
|
thinkingLevel,
|
|
365
442
|
rememberThinkingLevelChanges = false,
|
|
366
443
|
settingsPath,
|
|
444
|
+
state,
|
|
367
445
|
ctx,
|
|
368
446
|
dependencies = {},
|
|
369
447
|
}: RunBtwThreadOptions): Promise<BtwThreadResult> {
|
|
@@ -374,44 +452,52 @@ export async function runBtwThread({
|
|
|
374
452
|
const persistThinkingLevel =
|
|
375
453
|
dependencies.persistThinkingLevel ??
|
|
376
454
|
((level: BtwThinkingLevel) => updateBtwSettings({ thinkingLevel: level }, { settingsPath }));
|
|
377
|
-
const
|
|
455
|
+
const now = dependencies.now ?? Date.now;
|
|
456
|
+
const thread =
|
|
457
|
+
state?.thread ?? createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
|
|
378
458
|
const thinkingLevels = getSupportedThinkingLevels(selected.model);
|
|
379
459
|
const pendingWrites = new Set<Promise<void>>();
|
|
380
|
-
|
|
460
|
+
const steeringQuestions: string[] = [];
|
|
461
|
+
let activeThinkingLevel = clampThinkingLevel(
|
|
462
|
+
selected.model,
|
|
463
|
+
state?.thinkingLevel ?? thinkingLevel,
|
|
464
|
+
);
|
|
465
|
+
if (state) state.thinkingLevel = activeThinkingLevel;
|
|
381
466
|
let pendingQuestion = initialQuestion;
|
|
382
467
|
let composerDraft: string | undefined;
|
|
468
|
+
const createThinkingControl = (): BtwThreadThinkingControl => ({
|
|
469
|
+
level: activeThinkingLevel,
|
|
470
|
+
levels: thinkingLevels,
|
|
471
|
+
onChange: (level) => {
|
|
472
|
+
if (!thinkingLevels.includes(level)) return;
|
|
473
|
+
activeThinkingLevel = level;
|
|
474
|
+
if (state) state.thinkingLevel = level;
|
|
475
|
+
if (!rememberThinkingLevelChanges) return;
|
|
476
|
+
let write!: Promise<void>;
|
|
477
|
+
write = Promise.resolve()
|
|
478
|
+
.then(() => persistThinkingLevel(level))
|
|
479
|
+
.then(() => undefined)
|
|
480
|
+
.catch((error: unknown) => {
|
|
481
|
+
notifySafely(
|
|
482
|
+
ctx,
|
|
483
|
+
`Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
|
|
484
|
+
"warning",
|
|
485
|
+
);
|
|
486
|
+
})
|
|
487
|
+
.finally(() => pendingWrites.delete(write));
|
|
488
|
+
pendingWrites.add(write);
|
|
489
|
+
},
|
|
490
|
+
});
|
|
383
491
|
|
|
384
492
|
try {
|
|
385
493
|
while (true) {
|
|
386
494
|
if (!pendingQuestion) {
|
|
387
|
-
const thinking: BtwThreadThinkingControl = {
|
|
388
|
-
level: activeThinkingLevel,
|
|
389
|
-
levels: thinkingLevels,
|
|
390
|
-
onChange: (level) => {
|
|
391
|
-
if (!thinkingLevels.includes(level)) return;
|
|
392
|
-
activeThinkingLevel = level;
|
|
393
|
-
if (!rememberThinkingLevelChanges) return;
|
|
394
|
-
let write!: Promise<void>;
|
|
395
|
-
write = Promise.resolve()
|
|
396
|
-
.then(() => persistThinkingLevel(level))
|
|
397
|
-
.then(() => undefined)
|
|
398
|
-
.catch((error: unknown) => {
|
|
399
|
-
notifySafely(
|
|
400
|
-
ctx,
|
|
401
|
-
`Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
|
|
402
|
-
"warning",
|
|
403
|
-
);
|
|
404
|
-
})
|
|
405
|
-
.finally(() => pendingWrites.delete(write));
|
|
406
|
-
pendingWrites.add(write);
|
|
407
|
-
},
|
|
408
|
-
};
|
|
409
495
|
const action = await interact(
|
|
410
496
|
thread,
|
|
411
497
|
thread.turns.length > 0,
|
|
412
498
|
ctx,
|
|
413
499
|
composerDraft,
|
|
414
|
-
|
|
500
|
+
createThinkingControl(),
|
|
415
501
|
);
|
|
416
502
|
if (action.kind === "close") return { kind: "closed" };
|
|
417
503
|
if (action.kind === "bringToMain") {
|
|
@@ -430,7 +516,11 @@ export async function runBtwThread({
|
|
|
430
516
|
pendingQuestion = action.question;
|
|
431
517
|
}
|
|
432
518
|
|
|
433
|
-
const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx
|
|
519
|
+
const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx, {
|
|
520
|
+
questions: steeringQuestions,
|
|
521
|
+
submit: (question) => steeringQuestions.push(question),
|
|
522
|
+
thinking: createThinkingControl(),
|
|
523
|
+
});
|
|
434
524
|
if (result.kind === "aborted") {
|
|
435
525
|
notifySafely(ctx, "Cancelled", "info");
|
|
436
526
|
return { kind: "closed" };
|
|
@@ -442,8 +532,12 @@ export async function runBtwThread({
|
|
|
442
532
|
answer: result.message,
|
|
443
533
|
});
|
|
444
534
|
}
|
|
535
|
+
if (state) {
|
|
536
|
+
state.title ||= sanitizeSingleLine(pendingQuestion) || "Untitled side thread";
|
|
537
|
+
state.updatedAt = now();
|
|
538
|
+
}
|
|
445
539
|
|
|
446
|
-
pendingQuestion =
|
|
540
|
+
pendingQuestion = steeringQuestions.shift();
|
|
447
541
|
}
|
|
448
542
|
} finally {
|
|
449
543
|
await Promise.allSettled([...pendingWrites]);
|
|
@@ -766,9 +860,10 @@ async function askThreadQuestion(
|
|
|
766
860
|
selected: ResolvedBtwModel,
|
|
767
861
|
thinkingLevel: BtwThinkingLevel,
|
|
768
862
|
ctx: ExtensionCommandContext,
|
|
863
|
+
steering: BtwThreadSteeringControl,
|
|
769
864
|
) {
|
|
770
865
|
return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
|
|
771
|
-
(tui, theme,
|
|
866
|
+
(tui, theme, keybindings, done) => {
|
|
772
867
|
let settled = false;
|
|
773
868
|
const view = new BtwAnsweringView(
|
|
774
869
|
tui,
|
|
@@ -781,6 +876,13 @@ async function askThreadQuestion(
|
|
|
781
876
|
done({ kind: "aborted" });
|
|
782
877
|
},
|
|
783
878
|
thinkingLevel,
|
|
879
|
+
{
|
|
880
|
+
steering: {
|
|
881
|
+
questions: steering.questions,
|
|
882
|
+
onSubmit: steering.submit,
|
|
883
|
+
thinking: { ...steering.thinking, keybindings },
|
|
884
|
+
},
|
|
885
|
+
},
|
|
784
886
|
);
|
|
785
887
|
completeSideThreadTurn({
|
|
786
888
|
thread,
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
KeybindingsManager,
|
|
5
|
+
Theme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
type Component,
|
|
9
|
+
type OverlayHandle,
|
|
10
|
+
type TUI,
|
|
11
|
+
TuiAltScreen,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import { sanitizeSingleLine } from "./text.js";
|
|
15
|
+
|
|
16
|
+
type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
|
|
17
|
+
type BtwCustomFactory<T> = (
|
|
18
|
+
tui: TUI,
|
|
19
|
+
theme: Theme,
|
|
20
|
+
keybindings: KeybindingsManager,
|
|
21
|
+
done: (result: T) => void,
|
|
22
|
+
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>;
|
|
23
|
+
|
|
24
|
+
type BtwFullscreenTui = TUI & { flash?: (message: string, durationMs?: number) => void };
|
|
25
|
+
|
|
26
|
+
export type BtwFullscreenTuiFactory = (parent: TUI) => BtwFullscreenTui;
|
|
27
|
+
|
|
28
|
+
export interface BtwFullscreenDependencies {
|
|
29
|
+
createTui?: BtwFullscreenTuiFactory;
|
|
30
|
+
openUrl?: (url: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RunBtwFullscreen = <T>(
|
|
34
|
+
ctx: ExtensionCommandContext,
|
|
35
|
+
run: (ctx: ExtensionCommandContext) => Promise<T>,
|
|
36
|
+
) => Promise<T>;
|
|
37
|
+
|
|
38
|
+
type FullscreenOutcome<T> = { kind: "completed"; value: T } | { kind: "failed"; error: unknown };
|
|
39
|
+
|
|
40
|
+
class FullscreenUiDisposedError extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super("The dedicated pi-btw UI was disposed.");
|
|
43
|
+
this.name = "FullscreenUiDisposedError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runBtwFullscreen<T>(
|
|
48
|
+
ctx: ExtensionCommandContext,
|
|
49
|
+
run: (ctx: ExtensionCommandContext) => Promise<T>,
|
|
50
|
+
dependencies: BtwFullscreenDependencies = {},
|
|
51
|
+
): Promise<T> {
|
|
52
|
+
const createTui =
|
|
53
|
+
dependencies.createTui ??
|
|
54
|
+
((parent: TUI) => createBtwFullscreenTui(parent, dependencies.openUrl ?? openUrlInBrowser));
|
|
55
|
+
let liveEditorText = ctx.ui.getEditorText();
|
|
56
|
+
let restoreEditor = false;
|
|
57
|
+
const outcome = await ctx.ui.custom<FullscreenOutcome<T>>(
|
|
58
|
+
(parent, theme, keybindings, done) =>
|
|
59
|
+
new BtwFullscreenHost(
|
|
60
|
+
parent,
|
|
61
|
+
theme,
|
|
62
|
+
keybindings,
|
|
63
|
+
ctx,
|
|
64
|
+
run,
|
|
65
|
+
(value) => {
|
|
66
|
+
try {
|
|
67
|
+
liveEditorText = ctx.ui.getEditorText();
|
|
68
|
+
restoreEditor = true;
|
|
69
|
+
} catch {
|
|
70
|
+
// A replaced session owns a different editor and must not receive stale text.
|
|
71
|
+
}
|
|
72
|
+
done(value);
|
|
73
|
+
},
|
|
74
|
+
createTui,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
if (restoreEditor) {
|
|
78
|
+
try {
|
|
79
|
+
if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
|
|
80
|
+
} catch {
|
|
81
|
+
// A replaced session owns a different editor and must not receive stale restoration.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (outcome.kind === "failed") throw outcome.error;
|
|
85
|
+
return outcome.value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createBtwFullscreenTui(parent: TUI, openUrl: (url: string) => void): BtwFullscreenTui {
|
|
89
|
+
return new TuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), undefined, {
|
|
90
|
+
mouse: true,
|
|
91
|
+
openUrl,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Pi does not export its browser opener, so mirror its shell-free launcher for this isolated TUI.
|
|
96
|
+
function openUrlInBrowser(target: string): void {
|
|
97
|
+
const [command, args] =
|
|
98
|
+
process.platform === "darwin"
|
|
99
|
+
? ["open", [target]]
|
|
100
|
+
: process.platform === "win32"
|
|
101
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", target]]
|
|
102
|
+
: ["xdg-open", [target]];
|
|
103
|
+
spawn(command, args, { stdio: "ignore", detached: true })
|
|
104
|
+
.on("error", () => {})
|
|
105
|
+
.unref();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class BtwFullscreenHost<T> implements Component {
|
|
109
|
+
private fullscreen: BtwFullscreenTui | undefined;
|
|
110
|
+
private cancelActiveCustom: (() => void) | undefined;
|
|
111
|
+
private started = false;
|
|
112
|
+
private disposed = false;
|
|
113
|
+
private finished = false;
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly parent: TUI,
|
|
117
|
+
private readonly theme: Theme,
|
|
118
|
+
private readonly keybindings: KeybindingsManager,
|
|
119
|
+
private readonly ctx: ExtensionCommandContext,
|
|
120
|
+
private readonly run: (ctx: ExtensionCommandContext) => Promise<T>,
|
|
121
|
+
private readonly done: (outcome: FullscreenOutcome<T>) => void,
|
|
122
|
+
private readonly createTui: BtwFullscreenTuiFactory,
|
|
123
|
+
) {
|
|
124
|
+
queueMicrotask(() => void this.start());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
render(width: number): string[] {
|
|
128
|
+
return [truncateToWidth(this.theme.fg("muted", "Opening btw side thread…"), width)];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
invalidate(): void {}
|
|
132
|
+
|
|
133
|
+
dispose(): void {
|
|
134
|
+
if (this.disposed || this.finished) return;
|
|
135
|
+
this.disposed = true;
|
|
136
|
+
this.cancelActiveCustom?.();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async start(): Promise<void> {
|
|
140
|
+
if (this.started || this.finished) return;
|
|
141
|
+
this.started = true;
|
|
142
|
+
let outcome: FullscreenOutcome<T>;
|
|
143
|
+
let parentStopped = false;
|
|
144
|
+
let fullscreenCreated = false;
|
|
145
|
+
try {
|
|
146
|
+
if (this.disposed) throw new FullscreenUiDisposedError();
|
|
147
|
+
this.parent.stop({ preserveScreen: true });
|
|
148
|
+
parentStopped = true;
|
|
149
|
+
if (this.disposed) throw new FullscreenUiDisposedError();
|
|
150
|
+
this.fullscreen = this.createTui(this.parent);
|
|
151
|
+
fullscreenCreated = true;
|
|
152
|
+
this.fullscreen.start();
|
|
153
|
+
outcome = { kind: "completed", value: await this.run(this.createContext()) };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
outcome = { kind: "failed", error };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let cleanupError: unknown;
|
|
159
|
+
try {
|
|
160
|
+
this.cancelActiveCustom?.();
|
|
161
|
+
} catch (error) {
|
|
162
|
+
cleanupError = error;
|
|
163
|
+
}
|
|
164
|
+
if (fullscreenCreated) {
|
|
165
|
+
try {
|
|
166
|
+
this.fullscreen?.stop({ preserveScreen: true });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
cleanupError ??= error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (parentStopped) {
|
|
172
|
+
try {
|
|
173
|
+
this.parent.start();
|
|
174
|
+
this.parent.renderNow(false);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
cleanupError ??= error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (cleanupError !== undefined) outcome = { kind: "failed", error: cleanupError };
|
|
180
|
+
this.finished = true;
|
|
181
|
+
this.done(outcome);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private createContext(): ExtensionCommandContext {
|
|
185
|
+
const ui = new Proxy(this.ctx.ui, {
|
|
186
|
+
get: (target, property) => {
|
|
187
|
+
if (property === "custom") {
|
|
188
|
+
return <Value>(factory: BtwCustomFactory<Value>, options?: BtwCustomOptions) =>
|
|
189
|
+
this.showCustom(factory, options);
|
|
190
|
+
}
|
|
191
|
+
if (property === "notify") {
|
|
192
|
+
return (
|
|
193
|
+
message: string,
|
|
194
|
+
level?: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
|
|
195
|
+
) => {
|
|
196
|
+
target.notify(message, level);
|
|
197
|
+
const display = sanitizeSingleLine(message);
|
|
198
|
+
if (display) this.fullscreen?.flash?.(display);
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const value = Reflect.get(target, property, target) as unknown;
|
|
202
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
return new Proxy(this.ctx, {
|
|
206
|
+
get: (target, property) => (property === "ui" ? ui : Reflect.get(target, property, target)),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private showCustom<Value>(
|
|
211
|
+
factory: BtwCustomFactory<Value>,
|
|
212
|
+
options?: BtwCustomOptions,
|
|
213
|
+
): Promise<Value> {
|
|
214
|
+
const fullscreen = this.fullscreen;
|
|
215
|
+
if (!fullscreen || this.disposed || this.finished) {
|
|
216
|
+
return Promise.reject(new FullscreenUiDisposedError());
|
|
217
|
+
}
|
|
218
|
+
if (this.cancelActiveCustom) {
|
|
219
|
+
return Promise.reject(new Error("pi-btw attempted to open overlapping custom UI."));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return new Promise<Value>((resolve, reject) => {
|
|
223
|
+
let component: (Component & { dispose?(): void }) | undefined;
|
|
224
|
+
let overlay: OverlayHandle | undefined;
|
|
225
|
+
let mounted = false;
|
|
226
|
+
let factorySettled = false;
|
|
227
|
+
let closed = false;
|
|
228
|
+
let promiseSettled = false;
|
|
229
|
+
let componentDisposed = false;
|
|
230
|
+
let pendingValue: Value | undefined;
|
|
231
|
+
let hasPendingValue = false;
|
|
232
|
+
|
|
233
|
+
const disposeComponent = () => {
|
|
234
|
+
if (!component || componentDisposed) return;
|
|
235
|
+
componentDisposed = true;
|
|
236
|
+
try {
|
|
237
|
+
component.dispose?.();
|
|
238
|
+
} catch {
|
|
239
|
+
// Cleanup must continue so terminal ownership is restored.
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const unmount = () => {
|
|
243
|
+
let cleanupError: unknown;
|
|
244
|
+
try {
|
|
245
|
+
if (overlay) overlay.hide();
|
|
246
|
+
else if (mounted && component) fullscreen.removeChild(component);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
cleanupError = error;
|
|
249
|
+
}
|
|
250
|
+
if (overlay || mounted) {
|
|
251
|
+
try {
|
|
252
|
+
fullscreen.setFocus(null);
|
|
253
|
+
fullscreen.requestRender();
|
|
254
|
+
} catch (error) {
|
|
255
|
+
cleanupError ??= error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
disposeComponent();
|
|
259
|
+
if (cleanupError !== undefined) throw cleanupError;
|
|
260
|
+
};
|
|
261
|
+
const complete = () => {
|
|
262
|
+
if (promiseSettled || !hasPendingValue) return;
|
|
263
|
+
promiseSettled = true;
|
|
264
|
+
this.cancelActiveCustom = undefined;
|
|
265
|
+
if (!factorySettled) {
|
|
266
|
+
resolve(pendingValue as Value);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
unmount();
|
|
271
|
+
resolve(pendingValue as Value);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
reject(error);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const close = (value: Value) => {
|
|
277
|
+
if (closed || promiseSettled) return;
|
|
278
|
+
closed = true;
|
|
279
|
+
pendingValue = value;
|
|
280
|
+
hasPendingValue = true;
|
|
281
|
+
complete();
|
|
282
|
+
};
|
|
283
|
+
const fail = (error: unknown) => {
|
|
284
|
+
if (promiseSettled) return;
|
|
285
|
+
closed = true;
|
|
286
|
+
promiseSettled = true;
|
|
287
|
+
this.cancelActiveCustom = undefined;
|
|
288
|
+
try {
|
|
289
|
+
unmount();
|
|
290
|
+
reject(error);
|
|
291
|
+
} catch (cleanupError) {
|
|
292
|
+
reject(cleanupError);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
this.cancelActiveCustom = () => {
|
|
296
|
+
if (promiseSettled) return;
|
|
297
|
+
disposeComponent();
|
|
298
|
+
if (!promiseSettled) fail(new FullscreenUiDisposedError());
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
let created: ReturnType<BtwCustomFactory<Value>>;
|
|
302
|
+
try {
|
|
303
|
+
created = factory(fullscreen, this.theme, this.keybindings, close);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
factorySettled = true;
|
|
306
|
+
fail(error);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
Promise.resolve(created)
|
|
310
|
+
.then((value) => {
|
|
311
|
+
component = value;
|
|
312
|
+
factorySettled = true;
|
|
313
|
+
if (promiseSettled) {
|
|
314
|
+
disposeComponent();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (closed) {
|
|
318
|
+
complete();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (options?.overlay) {
|
|
322
|
+
const overlayOptions =
|
|
323
|
+
typeof options.overlayOptions === "function"
|
|
324
|
+
? options.overlayOptions()
|
|
325
|
+
: options.overlayOptions;
|
|
326
|
+
overlay = fullscreen.showOverlay(component, overlayOptions);
|
|
327
|
+
options.onHandle?.(overlay);
|
|
328
|
+
} else {
|
|
329
|
+
fullscreen.clear();
|
|
330
|
+
fullscreen.addChild(component);
|
|
331
|
+
mounted = true;
|
|
332
|
+
fullscreen.setFocus(component);
|
|
333
|
+
fullscreen.requestRender();
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
.catch(fail);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
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(
|
package/src/side-thread.ts
CHANGED
|
@@ -103,26 +103,30 @@ export async function completeSideThreadTurn({
|
|
|
103
103
|
completeSimple,
|
|
104
104
|
}: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
|
|
105
105
|
if (signal?.aborted) return { kind: "aborted" };
|
|
106
|
-
let response: AssistantMessage;
|
|
107
106
|
try {
|
|
108
|
-
response = await completeSimple(
|
|
107
|
+
const response = await completeSimple(
|
|
109
108
|
model,
|
|
110
109
|
{ systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
|
|
111
110
|
buildStreamOptions(auth, thinkingLevel, signal),
|
|
112
111
|
);
|
|
112
|
+
if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
|
|
113
|
+
if (!isAssistantMessage(response)) {
|
|
114
|
+
return { kind: "error", message: "The side model returned a malformed response." };
|
|
115
|
+
}
|
|
116
|
+
if (response.stopReason === "error") {
|
|
117
|
+
return {
|
|
118
|
+
kind: "error",
|
|
119
|
+
message: response.errorMessage ?? "The side model returned an error.",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const answer = extractAssistantText(response) || "No response received.";
|
|
124
|
+
thread.turns.push({ kind: "answered", question, answer, response });
|
|
125
|
+
return { kind: "answered", response, answer };
|
|
113
126
|
} catch (error: unknown) {
|
|
114
127
|
if (signal?.aborted) return { kind: "aborted" };
|
|
115
128
|
return { kind: "error", message: formatError(error) };
|
|
116
129
|
}
|
|
117
|
-
|
|
118
|
-
if (signal?.aborted || response.stopReason === "aborted") return { kind: "aborted" };
|
|
119
|
-
if (response.stopReason === "error") {
|
|
120
|
-
return { kind: "error", message: response.errorMessage ?? "The side model returned an error." };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const answer = extractAssistantText(response) || "No response received.";
|
|
124
|
-
thread.turns.push({ kind: "answered", question, answer, response });
|
|
125
|
-
return { kind: "answered", response, answer };
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
export interface CompleteSideQuestionOptions {
|
|
@@ -156,12 +160,28 @@ export async function completeSideQuestion({
|
|
|
156
160
|
|
|
157
161
|
export function extractAssistantText(response: AssistantMessage): string {
|
|
158
162
|
return response.content
|
|
159
|
-
.filter(
|
|
163
|
+
.filter(
|
|
164
|
+
(content): content is { type: "text"; text: string } =>
|
|
165
|
+
content !== null &&
|
|
166
|
+
typeof content === "object" &&
|
|
167
|
+
content.type === "text" &&
|
|
168
|
+
typeof content.text === "string",
|
|
169
|
+
)
|
|
160
170
|
.map((content) => content.text)
|
|
161
171
|
.join("\n")
|
|
162
172
|
.trim();
|
|
163
173
|
}
|
|
164
174
|
|
|
175
|
+
function isAssistantMessage(value: unknown): value is AssistantMessage {
|
|
176
|
+
if (value === null || typeof value !== "object") return false;
|
|
177
|
+
const candidate = value as Partial<AssistantMessage>;
|
|
178
|
+
return (
|
|
179
|
+
candidate.role === "assistant" &&
|
|
180
|
+
Array.isArray(candidate.content) &&
|
|
181
|
+
typeof candidate.stopReason === "string"
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
165
185
|
export function buildUserPrompt(question: string, conversationContext: string): string {
|
|
166
186
|
return [
|
|
167
187
|
"Answer this side question without modifying the main conversation.",
|
package/src/transcript-pager.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
CURSOR_MARKER,
|
|
12
12
|
Editor,
|
|
13
13
|
type EditorTheme,
|
|
14
|
+
type Focusable,
|
|
14
15
|
Key,
|
|
15
16
|
Loader,
|
|
16
17
|
Markdown,
|
|
@@ -23,6 +24,7 @@ import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
|
|
|
23
24
|
import { sanitizeSingleLine } from "./text.js";
|
|
24
25
|
|
|
25
26
|
const TRANSCRIPT_CHROME_LINES = 2;
|
|
27
|
+
const MAX_STEERING_DISPLAY_LINES = 3;
|
|
26
28
|
const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;C\u0007"];
|
|
27
29
|
// Pi renders a spacer above the custom component and a two-line built-in footer below it.
|
|
28
30
|
const RESERVED_APP_LINES = 3;
|
|
@@ -39,7 +41,15 @@ export interface BtwThinkingControl {
|
|
|
39
41
|
onChange: (level: BtwThinkingLevel) => void;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
export
|
|
44
|
+
export interface BtwAnsweringViewOptions {
|
|
45
|
+
steering?: {
|
|
46
|
+
questions: readonly string[];
|
|
47
|
+
onSubmit: (question: string) => void;
|
|
48
|
+
thinking?: BtwThinkingControl;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class BtwTranscriptPager implements Component, Focusable {
|
|
43
53
|
private readonly transcriptComponents: Component[];
|
|
44
54
|
private readonly editor: Editor;
|
|
45
55
|
private readonly canBringToMain: boolean;
|
|
@@ -242,15 +252,19 @@ export class BtwTranscriptPager implements Component {
|
|
|
242
252
|
}
|
|
243
253
|
}
|
|
244
254
|
|
|
245
|
-
export class BtwAnsweringView implements Component {
|
|
255
|
+
export class BtwAnsweringView implements Component, Focusable {
|
|
246
256
|
private readonly transcriptComponents: Component[];
|
|
247
257
|
private readonly loader: Loader;
|
|
258
|
+
private readonly editor: Editor | undefined;
|
|
248
259
|
private readonly controller = new AbortController();
|
|
249
260
|
private scrollOffset = 0;
|
|
250
261
|
private lastContentLineCount = 0;
|
|
251
262
|
private lastViewportHeight = 1;
|
|
252
263
|
private followBottom = true;
|
|
264
|
+
private warning: string | undefined;
|
|
253
265
|
private finished = false;
|
|
266
|
+
private isFocused = false;
|
|
267
|
+
private thinkingLevel: BtwThinkingLevel | undefined;
|
|
254
268
|
|
|
255
269
|
constructor(
|
|
256
270
|
private readonly tui: TUI,
|
|
@@ -258,15 +272,51 @@ export class BtwAnsweringView implements Component {
|
|
|
258
272
|
turns: readonly SideThreadTurn[],
|
|
259
273
|
pendingQuestion: string,
|
|
260
274
|
private readonly onCancel: () => void,
|
|
261
|
-
|
|
275
|
+
thinkingLevel?: BtwThinkingLevel,
|
|
276
|
+
private readonly options: BtwAnsweringViewOptions = {},
|
|
262
277
|
) {
|
|
263
278
|
this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
|
|
279
|
+
this.thinkingLevel = options.steering?.thinking?.level ?? thinkingLevel;
|
|
264
280
|
this.loader = new Loader(
|
|
265
281
|
this.tui,
|
|
266
282
|
(text) => this.theme.fg("accent", text),
|
|
267
283
|
(text) => this.theme.fg("muted", text),
|
|
268
284
|
"Answering…",
|
|
269
285
|
);
|
|
286
|
+
if (options.steering) {
|
|
287
|
+
const editorTheme: EditorTheme = {
|
|
288
|
+
borderColor: (text) => this.theme.fg("accent", text),
|
|
289
|
+
selectList: {
|
|
290
|
+
selectedPrefix: (text) => this.theme.fg("accent", text),
|
|
291
|
+
selectedText: (text) => this.theme.fg("accent", text),
|
|
292
|
+
description: (text) => this.theme.fg("muted", text),
|
|
293
|
+
scrollInfo: (text) => this.theme.fg("dim", text),
|
|
294
|
+
noMatch: (text) => this.theme.fg("warning", text),
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
this.editor = new Editor(this.tui, editorTheme);
|
|
298
|
+
this.editor.onChange = () => {
|
|
299
|
+
this.warning = undefined;
|
|
300
|
+
};
|
|
301
|
+
this.editor.onSubmit = (text) => {
|
|
302
|
+
const question = text.trim();
|
|
303
|
+
if (!question) {
|
|
304
|
+
this.warning = "Question cannot be empty";
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
options.steering?.onSubmit(question);
|
|
308
|
+
this.warning = undefined;
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
get focused(): boolean {
|
|
314
|
+
return this.isFocused;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
set focused(value: boolean) {
|
|
318
|
+
this.isFocused = value;
|
|
319
|
+
if (this.editor) this.editor.focused = value;
|
|
270
320
|
}
|
|
271
321
|
|
|
272
322
|
get signal(): AbortSignal {
|
|
@@ -276,21 +326,35 @@ export class BtwAnsweringView implements Component {
|
|
|
276
326
|
render(width: number): string[] {
|
|
277
327
|
const safeWidth = Math.max(1, width);
|
|
278
328
|
const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
|
|
279
|
-
const
|
|
329
|
+
const editorLines = this.editor?.render(safeWidth) ?? [];
|
|
330
|
+
const steeringCapacity = Math.max(
|
|
331
|
+
0,
|
|
332
|
+
availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES,
|
|
333
|
+
);
|
|
334
|
+
const steeringLines = renderSteeringLines(
|
|
335
|
+
this.options.steering?.questions ?? [],
|
|
336
|
+
safeWidth,
|
|
337
|
+
this.theme,
|
|
338
|
+
Math.min(MAX_STEERING_DISPLAY_LINES, steeringCapacity),
|
|
339
|
+
);
|
|
340
|
+
const viewportHeight = Math.max(
|
|
341
|
+
0,
|
|
342
|
+
availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES - steeringLines.length,
|
|
343
|
+
);
|
|
280
344
|
const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
|
|
281
345
|
this.lastContentLineCount = contentLines.length;
|
|
282
346
|
this.lastViewportHeight = viewportHeight;
|
|
283
347
|
if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
|
|
284
348
|
this.clampScrollOffset();
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
|
|
288
|
-
const lines = [
|
|
349
|
+
|
|
350
|
+
return fitComposerLayout(
|
|
289
351
|
renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
352
|
+
contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
|
|
353
|
+
this.renderFooter(safeWidth),
|
|
354
|
+
editorLines,
|
|
355
|
+
availableRows,
|
|
356
|
+
steeringLines,
|
|
357
|
+
);
|
|
294
358
|
}
|
|
295
359
|
|
|
296
360
|
handleInput(data: string): void {
|
|
@@ -302,21 +366,43 @@ export class BtwAnsweringView implements Component {
|
|
|
302
366
|
this.onCancel();
|
|
303
367
|
return;
|
|
304
368
|
}
|
|
369
|
+
const thinking = this.options.steering?.thinking;
|
|
370
|
+
if (
|
|
371
|
+
thinking &&
|
|
372
|
+
thinking.levels.length > 1 &&
|
|
373
|
+
thinking.keybindings.matches(data, "app.thinking.cycle")
|
|
374
|
+
) {
|
|
375
|
+
const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
|
|
376
|
+
const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
|
|
377
|
+
if (nextLevel) {
|
|
378
|
+
this.thinkingLevel = nextLevel;
|
|
379
|
+
thinking.onChange(nextLevel);
|
|
380
|
+
this.warning = undefined;
|
|
381
|
+
this.tui.requestRender();
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
305
385
|
if (matchesKey(data, Key.pageUp)) {
|
|
306
386
|
const previousOffset = this.scrollOffset;
|
|
307
387
|
this.scrollBy(-this.lastViewportHeight);
|
|
308
388
|
if (this.scrollOffset < previousOffset) this.followBottom = false;
|
|
309
389
|
this.tui.requestRender();
|
|
310
|
-
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
311
393
|
this.scrollBy(this.lastViewportHeight);
|
|
312
394
|
this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
|
|
313
395
|
this.tui.requestRender();
|
|
396
|
+
return;
|
|
314
397
|
}
|
|
398
|
+
this.editor?.handleInput(data);
|
|
399
|
+
this.tui.requestRender();
|
|
315
400
|
}
|
|
316
401
|
|
|
317
402
|
invalidate(): void {
|
|
318
403
|
for (const component of this.transcriptComponents) component.invalidate();
|
|
319
404
|
this.loader.invalidate();
|
|
405
|
+
this.editor?.invalidate();
|
|
320
406
|
}
|
|
321
407
|
|
|
322
408
|
finish(): void {
|
|
@@ -336,6 +422,26 @@ export class BtwAnsweringView implements Component {
|
|
|
336
422
|
this.onCancel();
|
|
337
423
|
}
|
|
338
424
|
|
|
425
|
+
private renderFooter(width: number): string {
|
|
426
|
+
if (this.warning) {
|
|
427
|
+
const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C cancel`;
|
|
428
|
+
return truncateToWidth(this.theme.fg("warning", warning), width);
|
|
429
|
+
}
|
|
430
|
+
const baseHint = this.editor ? "Enter steer • Ctrl+C cancel" : "Ctrl+C cancel";
|
|
431
|
+
const thinking = this.options.steering?.thinking;
|
|
432
|
+
const cycleHint =
|
|
433
|
+
thinking && thinking.levels.length > 1 && this.thinkingLevel
|
|
434
|
+
? ` • thinking ${this.thinkingLevel} • ${thinkingKeyLabel(thinking.keybindings)} cycle`
|
|
435
|
+
: "";
|
|
436
|
+
const scrollHint = this.getMaxScrollOffset() > 0 ? " • PgUp/PgDn history" : "";
|
|
437
|
+
const hints = `${baseHint}${cycleHint}${scrollHint}`;
|
|
438
|
+
const compactHints = this.editor ? "Enter • Ctrl+C" : "Ctrl+C";
|
|
439
|
+
const selectedHints = visibleWidth(hints) <= width ? hints : compactHints;
|
|
440
|
+
const loaderWidth = Math.max(1, width - visibleWidth(selectedHints) - 3);
|
|
441
|
+
const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
|
|
442
|
+
return truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", selectedHints)}`, width);
|
|
443
|
+
}
|
|
444
|
+
|
|
339
445
|
private scrollBy(delta: number): void {
|
|
340
446
|
this.scrollOffset += delta;
|
|
341
447
|
this.clampScrollOffset();
|
|
@@ -439,8 +545,9 @@ function fitComposerLayout(
|
|
|
439
545
|
footer: string,
|
|
440
546
|
editorLines: string[],
|
|
441
547
|
availableRows: number,
|
|
548
|
+
statusLines: string[] = [],
|
|
442
549
|
): string[] {
|
|
443
|
-
const lines = [header, ...contentLines, footer, ...editorLines];
|
|
550
|
+
const lines = [header, ...contentLines, ...statusLines, footer, ...editorLines];
|
|
444
551
|
if (lines.length <= availableRows) return lines;
|
|
445
552
|
if (availableRows <= 1) return [header];
|
|
446
553
|
const editorBudget = Math.max(0, availableRows - 2);
|
|
@@ -456,10 +563,42 @@ function fitEditorLines(editorLines: string[], budget: number): string[] {
|
|
|
456
563
|
return editorLines.slice(start, start + budget);
|
|
457
564
|
}
|
|
458
565
|
|
|
459
|
-
function
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
566
|
+
function renderSteeringLines(
|
|
567
|
+
questions: readonly string[],
|
|
568
|
+
width: number,
|
|
569
|
+
theme: Theme,
|
|
570
|
+
maxLines: number,
|
|
571
|
+
): string[] {
|
|
572
|
+
if (questions.length === 0 || maxLines <= 0) return [];
|
|
573
|
+
const formatQuestion = (question: string) =>
|
|
574
|
+
sanitizeSingleLine(question) || "(non-printing message)";
|
|
575
|
+
if (maxLines === 1 && questions.length > 1) {
|
|
576
|
+
return [
|
|
577
|
+
truncateToWidth(
|
|
578
|
+
theme.fg(
|
|
579
|
+
"dim",
|
|
580
|
+
`Steering (+${questions.length - 1} more): ${formatQuestion(questions[0] ?? "")}`,
|
|
581
|
+
),
|
|
582
|
+
width,
|
|
583
|
+
),
|
|
584
|
+
];
|
|
585
|
+
}
|
|
586
|
+
const hasOverflow = questions.length > maxLines;
|
|
587
|
+
const questionLimit = hasOverflow ? Math.max(1, maxLines - 1) : maxLines;
|
|
588
|
+
const lines = questions
|
|
589
|
+
.slice(0, questionLimit)
|
|
590
|
+
.map((question) =>
|
|
591
|
+
truncateToWidth(theme.fg("dim", `Steering: ${formatQuestion(question)}`), width),
|
|
592
|
+
);
|
|
593
|
+
if (hasOverflow) {
|
|
594
|
+
lines.push(
|
|
595
|
+
truncateToWidth(
|
|
596
|
+
theme.fg("dim", `Steering: … +${questions.length - questionLimit} more`),
|
|
597
|
+
width,
|
|
598
|
+
),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
return lines;
|
|
463
602
|
}
|
|
464
603
|
|
|
465
604
|
function stripShellIntegrationMarkers(line: string): string {
|