@narumitw/pi-btw 0.49.6 → 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 +20 -7
- package/package.json +4 -4
- package/src/btw.ts +57 -33
- package/src/fullscreen-ui.ts +339 -0
- package/src/side-thread.ts +32 -12
- 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`.
|
|
@@ -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
|
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",
|
|
@@ -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,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,
|
|
@@ -213,6 +214,7 @@ export interface BtwExtensionDependencies {
|
|
|
213
214
|
loadSettings?: typeof loadSettingsForCommand;
|
|
214
215
|
resolveModel?: typeof resolveBtwModelWithLoader;
|
|
215
216
|
runThread?: typeof runBtwThread;
|
|
217
|
+
runFullscreen?: RunBtwFullscreen;
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
|
|
@@ -220,6 +222,7 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
220
222
|
const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
|
|
221
223
|
const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
|
|
222
224
|
const runThread = dependencies.runThread ?? runBtwThread;
|
|
225
|
+
const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
|
|
223
226
|
pi.registerCommand("btw", {
|
|
224
227
|
description: "Ask a quick side question without adding it to the main conversation",
|
|
225
228
|
handler: async (args, ctx) => {
|
|
@@ -241,13 +244,15 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
241
244
|
return;
|
|
242
245
|
}
|
|
243
246
|
|
|
244
|
-
await
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
);
|
|
251
256
|
},
|
|
252
257
|
});
|
|
253
258
|
}
|
|
@@ -336,6 +341,12 @@ export type BtwThreadResult = { kind: "closed" };
|
|
|
336
341
|
|
|
337
342
|
type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
|
|
338
343
|
|
|
344
|
+
interface BtwThreadSteeringControl {
|
|
345
|
+
questions: readonly string[];
|
|
346
|
+
submit: (question: string) => void;
|
|
347
|
+
thinking: BtwThreadThinkingControl;
|
|
348
|
+
}
|
|
349
|
+
|
|
339
350
|
type BtwBringToMainChoice =
|
|
340
351
|
| BtwThreadResult
|
|
341
352
|
| {
|
|
@@ -377,41 +388,42 @@ export async function runBtwThread({
|
|
|
377
388
|
const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
|
|
378
389
|
const thinkingLevels = getSupportedThinkingLevels(selected.model);
|
|
379
390
|
const pendingWrites = new Set<Promise<void>>();
|
|
391
|
+
const steeringQuestions: string[] = [];
|
|
380
392
|
let activeThinkingLevel = clampThinkingLevel(selected.model, thinkingLevel);
|
|
381
393
|
let pendingQuestion = initialQuestion;
|
|
382
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
|
+
});
|
|
383
417
|
|
|
384
418
|
try {
|
|
385
419
|
while (true) {
|
|
386
420
|
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
421
|
const action = await interact(
|
|
410
422
|
thread,
|
|
411
423
|
thread.turns.length > 0,
|
|
412
424
|
ctx,
|
|
413
425
|
composerDraft,
|
|
414
|
-
|
|
426
|
+
createThinkingControl(),
|
|
415
427
|
);
|
|
416
428
|
if (action.kind === "close") return { kind: "closed" };
|
|
417
429
|
if (action.kind === "bringToMain") {
|
|
@@ -430,7 +442,11 @@ export async function runBtwThread({
|
|
|
430
442
|
pendingQuestion = action.question;
|
|
431
443
|
}
|
|
432
444
|
|
|
433
|
-
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
|
+
});
|
|
434
450
|
if (result.kind === "aborted") {
|
|
435
451
|
notifySafely(ctx, "Cancelled", "info");
|
|
436
452
|
return { kind: "closed" };
|
|
@@ -443,7 +459,7 @@ export async function runBtwThread({
|
|
|
443
459
|
});
|
|
444
460
|
}
|
|
445
461
|
|
|
446
|
-
pendingQuestion =
|
|
462
|
+
pendingQuestion = steeringQuestions.shift();
|
|
447
463
|
}
|
|
448
464
|
} finally {
|
|
449
465
|
await Promise.allSettled([...pendingWrites]);
|
|
@@ -766,9 +782,10 @@ async function askThreadQuestion(
|
|
|
766
782
|
selected: ResolvedBtwModel,
|
|
767
783
|
thinkingLevel: BtwThinkingLevel,
|
|
768
784
|
ctx: ExtensionCommandContext,
|
|
785
|
+
steering: BtwThreadSteeringControl,
|
|
769
786
|
) {
|
|
770
787
|
return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
|
|
771
|
-
(tui, theme,
|
|
788
|
+
(tui, theme, keybindings, done) => {
|
|
772
789
|
let settled = false;
|
|
773
790
|
const view = new BtwAnsweringView(
|
|
774
791
|
tui,
|
|
@@ -781,6 +798,13 @@ async function askThreadQuestion(
|
|
|
781
798
|
done({ kind: "aborted" });
|
|
782
799
|
},
|
|
783
800
|
thinkingLevel,
|
|
801
|
+
{
|
|
802
|
+
steering: {
|
|
803
|
+
questions: steering.questions,
|
|
804
|
+
onSubmit: steering.submit,
|
|
805
|
+
thinking: { ...steering.thinking, keybindings },
|
|
806
|
+
},
|
|
807
|
+
},
|
|
784
808
|
);
|
|
785
809
|
completeSideThreadTurn({
|
|
786
810
|
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/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 {
|