@narumitw/pi-btw 0.49.5 → 0.49.7
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 +22 -9
- package/package.json +8 -5
- package/src/btw.ts +71 -34
- package/src/fullscreen-ui.ts +339 -0
- package/src/side-thread.ts +36 -46
- package/src/transcript-pager.ts +157 -18
package/README.md
CHANGED
|
@@ -10,8 +10,10 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
|
|
|
10
10
|
|
|
11
11
|
- Adds a `/btw` menu for starting a side thread or changing pi-btw settings.
|
|
12
12
|
- Keeps `/btw <question>` as a direct fast path.
|
|
13
|
-
- Answers side questions in a
|
|
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
|
+
- Queues Pi-style `Steering` questions while an answer is running and processes them one at a time.
|
|
15
17
|
- Optionally brings the latest answer, a question-to-end suffix, an exact line range, or the entire side thread into the main editor.
|
|
16
18
|
- Uses the current session branch as context.
|
|
17
19
|
- Uses Pi's current model or an independent model selected in `pi-btw.json`.
|
|
@@ -34,7 +36,7 @@ pi -e npm:@narumitw/pi-btw
|
|
|
34
36
|
Try this package locally from the repository root:
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
|
-
pi -e ./
|
|
39
|
+
pi -e ./packages/pi-btw
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
## 🚀 Usage
|
|
@@ -58,8 +60,13 @@ Examples:
|
|
|
58
60
|
Running `/btw` alone opens a two-row menu. **Start side thread** is selected first, so pressing
|
|
59
61
|
`Enter` opens an empty ephemeral side thread; **Settings** changes the starting thinking level
|
|
60
62
|
and whether shortcut changes are remembered. `/btw <question>` bypasses this menu, and its answer
|
|
61
|
-
opens above the side-thread editor.
|
|
62
|
-
|
|
63
|
+
opens above the side-thread editor. The side thread uses a dedicated full-screen terminal view.
|
|
64
|
+
The main agent continues running in the background, but its screen rendering stays suspended until
|
|
65
|
+
`/btw` closes, so new main-thread output cannot move a mouse selection inside the side thread.
|
|
66
|
+
Drag the primary mouse button across side-thread text to select and copy it through Pi's terminal
|
|
67
|
+
clipboard support. Returning from `/btw` redraws the main view with everything produced while it
|
|
68
|
+
was hidden. A compact `btw · side thread` header stays fixed above the content so the ephemeral
|
|
69
|
+
workspace remains recognizable while scrolling. Messages use Pi's normal
|
|
63
70
|
user and assistant presentation without numbered turns or role labels. Type each question and press
|
|
64
71
|
`Enter`; no follow-up shortcut is required.
|
|
65
72
|
Previous side questions and answers remain available to the model and visible for that
|
|
@@ -68,10 +75,16 @@ invocation. The side-thread header shows its current thinking level. Press Pi's
|
|
|
68
75
|
supported by the side-thread model; every later question uses the displayed level until it is
|
|
69
76
|
changed again. By default, each shortcut change is also written to `pi-btw.json` for the next
|
|
70
77
|
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
|
-
|
|
78
|
+
current side thread. Neither path changes the main session's thinking level.
|
|
79
|
+
While a response is running, the transcript and composer remain visible above an `Answering…`
|
|
80
|
+
status.
|
|
81
|
+
Type another question and press `Enter` to queue it as `Steering`; queued questions are shown in
|
|
82
|
+
submission order and answered one at a time after the active response completes.
|
|
83
|
+
A queued question uses the side thread's thinking level when its turn begins.
|
|
84
|
+
A failed active response is shown in the transcript and does not discard later steering questions.
|
|
85
|
+
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.
|
|
75
88
|
|
|
76
89
|
After at least one successful answer, press `Ctrl+R` to bring selected context to the main
|
|
77
90
|
editor. The scope menu shows the size of the latest question and answer and the entire side
|
|
@@ -154,7 +167,7 @@ Normal assistant messages become part of the main Pi conversation and can distra
|
|
|
154
167
|
## 🗂️ Package layout
|
|
155
168
|
|
|
156
169
|
```txt
|
|
157
|
-
|
|
170
|
+
packages/pi-btw/
|
|
158
171
|
├── src/
|
|
159
172
|
│ ├── index.ts
|
|
160
173
|
│ ├── btw.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-btw",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.7",
|
|
4
4
|
"description": "Pi extension that adds a /btw side-question command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"./src/index.ts"
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
|
+
"piExtension": {
|
|
26
|
+
"lifecycle": "stable"
|
|
27
|
+
},
|
|
25
28
|
"scripts": {
|
|
26
29
|
"check": "biome check . && npm run typecheck",
|
|
27
30
|
"format": "biome check --write .",
|
|
@@ -29,15 +32,15 @@
|
|
|
29
32
|
},
|
|
30
33
|
"devDependencies": {
|
|
31
34
|
"@biomejs/biome": "2.5.7",
|
|
32
|
-
"@earendil-works/pi-ai": "0.
|
|
33
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
34
|
-
"@earendil-works/pi-tui": "0.
|
|
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",
|
|
35
38
|
"typescript": "7.0.2"
|
|
36
39
|
},
|
|
37
40
|
"repository": {
|
|
38
41
|
"type": "git",
|
|
39
42
|
"url": "https://github.com/narumiruna/pi-extensions",
|
|
40
|
-
"directory": "
|
|
43
|
+
"directory": "packages/pi-btw"
|
|
41
44
|
},
|
|
42
45
|
"dependencies": {
|
|
43
46
|
"@narumitw/pi-tui-kit": "^0.49.1"
|
package/src/btw.ts
CHANGED
|
@@ -25,6 +25,7 @@ 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,
|
|
30
31
|
runBtwMenuPreservingEditor,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
import {
|
|
41
42
|
BTW_THINKING_LEVELS,
|
|
42
43
|
type BtwThinkingLevel,
|
|
44
|
+
type CompleteSimpleFunction,
|
|
43
45
|
completeSideThreadTurn,
|
|
44
46
|
createSideThread,
|
|
45
47
|
type SideQuestionAuth,
|
|
@@ -66,7 +68,6 @@ export {
|
|
|
66
68
|
type BtwThinkingLevel,
|
|
67
69
|
buildUserPrompt,
|
|
68
70
|
completeSideQuestion,
|
|
69
|
-
loadCompleteSimple,
|
|
70
71
|
} from "./side-thread.js";
|
|
71
72
|
export { sanitizeSingleLine } from "./text.js";
|
|
72
73
|
|
|
@@ -82,6 +83,18 @@ type BtwModelRegistry = Pick<
|
|
|
82
83
|
"find" | "getApiKeyAndHeaders"
|
|
83
84
|
>;
|
|
84
85
|
|
|
86
|
+
type BtwProviderRegistry = Pick<ExtensionCommandContext["modelRegistry"], "getProvider">;
|
|
87
|
+
|
|
88
|
+
export function createModelRegistryCompleteSimple(
|
|
89
|
+
modelRegistry: BtwProviderRegistry,
|
|
90
|
+
): CompleteSimpleFunction {
|
|
91
|
+
return async (model, context, options) => {
|
|
92
|
+
const provider = modelRegistry.getProvider(model.provider);
|
|
93
|
+
if (!provider) throw new Error(`No provider registered for model provider: ${model.provider}`);
|
|
94
|
+
return provider.streamSimple(model, context, options).result();
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
85
98
|
interface ResolveBtwModelOptions {
|
|
86
99
|
settings: BtwSettings;
|
|
87
100
|
currentModel: Model<Api> | undefined;
|
|
@@ -201,6 +214,7 @@ export interface BtwExtensionDependencies {
|
|
|
201
214
|
loadSettings?: typeof loadSettingsForCommand;
|
|
202
215
|
resolveModel?: typeof resolveBtwModelWithLoader;
|
|
203
216
|
runThread?: typeof runBtwThread;
|
|
217
|
+
runFullscreen?: RunBtwFullscreen;
|
|
204
218
|
}
|
|
205
219
|
|
|
206
220
|
export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
|
|
@@ -208,6 +222,7 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
208
222
|
const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
|
|
209
223
|
const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
|
|
210
224
|
const runThread = dependencies.runThread ?? runBtwThread;
|
|
225
|
+
const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
|
|
211
226
|
pi.registerCommand("btw", {
|
|
212
227
|
description: "Ask a quick side question without adding it to the main conversation",
|
|
213
228
|
handler: async (args, ctx) => {
|
|
@@ -229,13 +244,15 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
229
244
|
return;
|
|
230
245
|
}
|
|
231
246
|
|
|
232
|
-
await
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
);
|
|
239
256
|
},
|
|
240
257
|
});
|
|
241
258
|
}
|
|
@@ -324,6 +341,12 @@ export type BtwThreadResult = { kind: "closed" };
|
|
|
324
341
|
|
|
325
342
|
type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
|
|
326
343
|
|
|
344
|
+
interface BtwThreadSteeringControl {
|
|
345
|
+
questions: readonly string[];
|
|
346
|
+
submit: (question: string) => void;
|
|
347
|
+
thinking: BtwThreadThinkingControl;
|
|
348
|
+
}
|
|
349
|
+
|
|
327
350
|
type BtwBringToMainChoice =
|
|
328
351
|
| BtwThreadResult
|
|
329
352
|
| {
|
|
@@ -365,41 +388,42 @@ export async function runBtwThread({
|
|
|
365
388
|
const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
|
|
366
389
|
const thinkingLevels = getSupportedThinkingLevels(selected.model);
|
|
367
390
|
const pendingWrites = new Set<Promise<void>>();
|
|
391
|
+
const steeringQuestions: string[] = [];
|
|
368
392
|
let activeThinkingLevel = clampThinkingLevel(selected.model, thinkingLevel);
|
|
369
393
|
let pendingQuestion = initialQuestion;
|
|
370
394
|
let composerDraft: string | undefined;
|
|
395
|
+
const createThinkingControl = (): BtwThreadThinkingControl => ({
|
|
396
|
+
level: activeThinkingLevel,
|
|
397
|
+
levels: thinkingLevels,
|
|
398
|
+
onChange: (level) => {
|
|
399
|
+
if (!thinkingLevels.includes(level)) return;
|
|
400
|
+
activeThinkingLevel = level;
|
|
401
|
+
if (!rememberThinkingLevelChanges) return;
|
|
402
|
+
let write!: Promise<void>;
|
|
403
|
+
write = Promise.resolve()
|
|
404
|
+
.then(() => persistThinkingLevel(level))
|
|
405
|
+
.then(() => undefined)
|
|
406
|
+
.catch((error: unknown) => {
|
|
407
|
+
notifySafely(
|
|
408
|
+
ctx,
|
|
409
|
+
`Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
|
|
410
|
+
"warning",
|
|
411
|
+
);
|
|
412
|
+
})
|
|
413
|
+
.finally(() => pendingWrites.delete(write));
|
|
414
|
+
pendingWrites.add(write);
|
|
415
|
+
},
|
|
416
|
+
});
|
|
371
417
|
|
|
372
418
|
try {
|
|
373
419
|
while (true) {
|
|
374
420
|
if (!pendingQuestion) {
|
|
375
|
-
const thinking: BtwThreadThinkingControl = {
|
|
376
|
-
level: activeThinkingLevel,
|
|
377
|
-
levels: thinkingLevels,
|
|
378
|
-
onChange: (level) => {
|
|
379
|
-
if (!thinkingLevels.includes(level)) return;
|
|
380
|
-
activeThinkingLevel = level;
|
|
381
|
-
if (!rememberThinkingLevelChanges) return;
|
|
382
|
-
let write!: Promise<void>;
|
|
383
|
-
write = Promise.resolve()
|
|
384
|
-
.then(() => persistThinkingLevel(level))
|
|
385
|
-
.then(() => undefined)
|
|
386
|
-
.catch((error: unknown) => {
|
|
387
|
-
notifySafely(
|
|
388
|
-
ctx,
|
|
389
|
-
`Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
|
|
390
|
-
"warning",
|
|
391
|
-
);
|
|
392
|
-
})
|
|
393
|
-
.finally(() => pendingWrites.delete(write));
|
|
394
|
-
pendingWrites.add(write);
|
|
395
|
-
},
|
|
396
|
-
};
|
|
397
421
|
const action = await interact(
|
|
398
422
|
thread,
|
|
399
423
|
thread.turns.length > 0,
|
|
400
424
|
ctx,
|
|
401
425
|
composerDraft,
|
|
402
|
-
|
|
426
|
+
createThinkingControl(),
|
|
403
427
|
);
|
|
404
428
|
if (action.kind === "close") return { kind: "closed" };
|
|
405
429
|
if (action.kind === "bringToMain") {
|
|
@@ -418,7 +442,11 @@ export async function runBtwThread({
|
|
|
418
442
|
pendingQuestion = action.question;
|
|
419
443
|
}
|
|
420
444
|
|
|
421
|
-
const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx
|
|
445
|
+
const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx, {
|
|
446
|
+
questions: steeringQuestions,
|
|
447
|
+
submit: (question) => steeringQuestions.push(question),
|
|
448
|
+
thinking: createThinkingControl(),
|
|
449
|
+
});
|
|
422
450
|
if (result.kind === "aborted") {
|
|
423
451
|
notifySafely(ctx, "Cancelled", "info");
|
|
424
452
|
return { kind: "closed" };
|
|
@@ -431,7 +459,7 @@ export async function runBtwThread({
|
|
|
431
459
|
});
|
|
432
460
|
}
|
|
433
461
|
|
|
434
|
-
pendingQuestion =
|
|
462
|
+
pendingQuestion = steeringQuestions.shift();
|
|
435
463
|
}
|
|
436
464
|
} finally {
|
|
437
465
|
await Promise.allSettled([...pendingWrites]);
|
|
@@ -754,9 +782,10 @@ async function askThreadQuestion(
|
|
|
754
782
|
selected: ResolvedBtwModel,
|
|
755
783
|
thinkingLevel: BtwThinkingLevel,
|
|
756
784
|
ctx: ExtensionCommandContext,
|
|
785
|
+
steering: BtwThreadSteeringControl,
|
|
757
786
|
) {
|
|
758
787
|
return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
|
|
759
|
-
(tui, theme,
|
|
788
|
+
(tui, theme, keybindings, done) => {
|
|
760
789
|
let settled = false;
|
|
761
790
|
const view = new BtwAnsweringView(
|
|
762
791
|
tui,
|
|
@@ -769,6 +798,13 @@ async function askThreadQuestion(
|
|
|
769
798
|
done({ kind: "aborted" });
|
|
770
799
|
},
|
|
771
800
|
thinkingLevel,
|
|
801
|
+
{
|
|
802
|
+
steering: {
|
|
803
|
+
questions: steering.questions,
|
|
804
|
+
onSubmit: steering.submit,
|
|
805
|
+
thinking: { ...steering.thinking, keybindings },
|
|
806
|
+
},
|
|
807
|
+
},
|
|
772
808
|
);
|
|
773
809
|
completeSideThreadTurn({
|
|
774
810
|
thread,
|
|
@@ -777,6 +813,7 @@ async function askThreadQuestion(
|
|
|
777
813
|
thinkingLevel,
|
|
778
814
|
auth: selected.auth,
|
|
779
815
|
signal: view.signal,
|
|
816
|
+
completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry),
|
|
780
817
|
}).then((result) => {
|
|
781
818
|
if (settled) return;
|
|
782
819
|
settled = true;
|
|
@@ -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/side-thread.ts
CHANGED
|
@@ -33,36 +33,6 @@ export type CompleteSimpleFunction = <TApi extends Api>(
|
|
|
33
33
|
options?: SimpleStreamOptions,
|
|
34
34
|
) => Promise<AssistantMessage>;
|
|
35
35
|
|
|
36
|
-
type ModuleImporter = (moduleId: string) => Promise<unknown>;
|
|
37
|
-
|
|
38
|
-
function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
|
|
39
|
-
return (
|
|
40
|
-
typeof value === "object" &&
|
|
41
|
-
value !== null &&
|
|
42
|
-
typeof Reflect.get(value, "completeSimple") === "function"
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function loadCompleteSimple(
|
|
47
|
-
importModule: ModuleImporter = (moduleId) => import(moduleId),
|
|
48
|
-
): Promise<CompleteSimpleFunction> {
|
|
49
|
-
let importError: unknown;
|
|
50
|
-
for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
|
|
51
|
-
try {
|
|
52
|
-
const module = await importModule(moduleId);
|
|
53
|
-
if (hasCompleteSimple(module)) return module.completeSimple;
|
|
54
|
-
} catch (error: unknown) {
|
|
55
|
-
importError = error;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
throw new Error("@earendil-works/pi-ai does not export completeSimple", {
|
|
60
|
-
cause: importError,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const defaultCompleteSimple = await loadCompleteSimple();
|
|
65
|
-
|
|
66
36
|
export type SideThreadTurn =
|
|
67
37
|
| {
|
|
68
38
|
kind: "answered";
|
|
@@ -115,7 +85,7 @@ export interface CompleteSideThreadTurnOptions {
|
|
|
115
85
|
thinkingLevel: BtwThinkingLevel;
|
|
116
86
|
auth: SideQuestionAuth;
|
|
117
87
|
signal?: AbortSignal;
|
|
118
|
-
completeSimple
|
|
88
|
+
completeSimple: CompleteSimpleFunction;
|
|
119
89
|
}
|
|
120
90
|
|
|
121
91
|
export type CompleteSideThreadTurnResult =
|
|
@@ -130,29 +100,33 @@ export async function completeSideThreadTurn({
|
|
|
130
100
|
thinkingLevel,
|
|
131
101
|
auth,
|
|
132
102
|
signal,
|
|
133
|
-
completeSimple
|
|
103
|
+
completeSimple,
|
|
134
104
|
}: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
|
|
135
105
|
if (signal?.aborted) return { kind: "aborted" };
|
|
136
|
-
let response: AssistantMessage;
|
|
137
106
|
try {
|
|
138
|
-
response = await completeSimple(
|
|
107
|
+
const response = await completeSimple(
|
|
139
108
|
model,
|
|
140
109
|
{ systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
|
|
141
110
|
buildStreamOptions(auth, thinkingLevel, signal),
|
|
142
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 };
|
|
143
126
|
} catch (error: unknown) {
|
|
144
127
|
if (signal?.aborted) return { kind: "aborted" };
|
|
145
128
|
return { kind: "error", message: formatError(error) };
|
|
146
129
|
}
|
|
147
|
-
|
|
148
|
-
if (signal?.aborted || response.stopReason === "aborted") return { kind: "aborted" };
|
|
149
|
-
if (response.stopReason === "error") {
|
|
150
|
-
return { kind: "error", message: response.errorMessage ?? "The side model returned an error." };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const answer = extractAssistantText(response) || "No response received.";
|
|
154
|
-
thread.turns.push({ kind: "answered", question, answer, response });
|
|
155
|
-
return { kind: "answered", response, answer };
|
|
156
130
|
}
|
|
157
131
|
|
|
158
132
|
export interface CompleteSideQuestionOptions {
|
|
@@ -162,7 +136,7 @@ export interface CompleteSideQuestionOptions {
|
|
|
162
136
|
thinkingLevel: BtwThinkingLevel;
|
|
163
137
|
auth: SideQuestionAuth;
|
|
164
138
|
signal?: AbortSignal;
|
|
165
|
-
completeSimple
|
|
139
|
+
completeSimple: CompleteSimpleFunction;
|
|
166
140
|
}
|
|
167
141
|
|
|
168
142
|
export async function completeSideQuestion({
|
|
@@ -172,7 +146,7 @@ export async function completeSideQuestion({
|
|
|
172
146
|
thinkingLevel,
|
|
173
147
|
auth,
|
|
174
148
|
signal,
|
|
175
|
-
completeSimple
|
|
149
|
+
completeSimple,
|
|
176
150
|
}: CompleteSideQuestionOptions): Promise<AssistantMessage> {
|
|
177
151
|
return completeSimple(
|
|
178
152
|
model,
|
|
@@ -186,12 +160,28 @@ export async function completeSideQuestion({
|
|
|
186
160
|
|
|
187
161
|
export function extractAssistantText(response: AssistantMessage): string {
|
|
188
162
|
return response.content
|
|
189
|
-
.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
|
+
)
|
|
190
170
|
.map((content) => content.text)
|
|
191
171
|
.join("\n")
|
|
192
172
|
.trim();
|
|
193
173
|
}
|
|
194
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
|
+
|
|
195
185
|
export function buildUserPrompt(question: string, conversationContext: string): string {
|
|
196
186
|
return [
|
|
197
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 {
|