@narumitw/pi-btw 0.52.0 → 0.54.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 +14 -5
- package/package.json +2 -2
- package/src/btw.ts +36 -40
- package/src/fullscreen-ui.ts +22 -2
- package/src/main-tree-picker.ts +390 -0
- package/src/menu.ts +45 -2
- package/src/transcript-pager.ts +136 -45
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@ 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 or resuming an in-memory side thread or changing pi-btw settings.
|
|
11
|
+
- Adds a `/btw` menu for starting or resuming an in-memory side thread, choosing context from the main session tree, or changing pi-btw settings.
|
|
12
|
+
- Starts a fresh side thread from any persisted main-session branch without switching the main branch.
|
|
12
13
|
- Keeps `/btw <question>` as a direct fast path that always starts a fresh side thread.
|
|
13
14
|
- Answers side questions in a dedicated, scrollable full-screen UI.
|
|
14
15
|
- Keeps mouse-drag copying stable while the main agent continues running in the background.
|
|
@@ -59,6 +60,11 @@ Examples:
|
|
|
59
60
|
```
|
|
60
61
|
|
|
61
62
|
Running `/btw` alone opens a menu with **Start side thread** selected first.
|
|
63
|
+
**Start from main thread tree…** opens Pi's native session tree and uses the root-to-selected-entry path, including the selected entry, as the new side thread's context.
|
|
64
|
+
Selecting context does not navigate, fork, append to, or switch the main conversation, and it preserves the main editor draft.
|
|
65
|
+
The selector is a snapshot of entries persisted when it opens, while the resulting side thread keeps an immutable context snapshot even if the main conversation later changes.
|
|
66
|
+
Press `Escape` to return to the `/btw` menu or `Ctrl+C` to close the flow.
|
|
67
|
+
The native tree controls remain available: copying reports success or failure, and an explicit `Shift+L` label edit persists through Pi as the only main-session mutation available from this selector.
|
|
62
68
|
When the current Pi session has non-empty side threads in memory, **Resume side thread** opens a bounded searchable choice list.
|
|
63
69
|
Search matches the displayed first question and question count while returning the thread's raw in-memory ID.
|
|
64
70
|
**Settings** changes the starting thinking level and whether shortcut changes for fixed levels are remembered.
|
|
@@ -90,10 +96,12 @@ Type another question and press `Enter` to queue it as `Steering`; queued questi
|
|
|
90
96
|
submission order and answered one at a time after the active response completes.
|
|
91
97
|
A queued question uses the side thread's thinking level when its turn begins.
|
|
92
98
|
A failed active response is shown in the transcript and does not discard later steering questions.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
Use the mouse wheel or trackpad to scroll transcript history like Pi's main thread.
|
|
100
|
+
Keyboard `PgUp`/`PgDn` history navigation remains available.
|
|
101
|
+
It appears in the footer only when the transcript can scroll.
|
|
102
|
+
Press `Ctrl+C` to cancel the active response and discard the ephemeral side-thread draft and steering queue.
|
|
103
|
+
Completed questions, answers, and visible errors remain available through Resume until the current extension instance ends.
|
|
104
|
+
Steering remains entirely inside pi-btw and never appends to the main conversation or editor.
|
|
97
105
|
|
|
98
106
|
After at least one successful answer, press `Ctrl+R` to bring selected context to the main
|
|
99
107
|
editor. The scope menu shows the size of the latest question and answer and the entire side
|
|
@@ -188,6 +196,7 @@ packages/pi-btw/
|
|
|
188
196
|
│ ├── index.ts
|
|
189
197
|
│ ├── btw.ts
|
|
190
198
|
│ ├── bring-to-main.ts
|
|
199
|
+
│ ├── main-tree-picker.ts
|
|
191
200
|
│ ├── menu.ts
|
|
192
201
|
│ ├── settings.ts
|
|
193
202
|
│ ├── side-thread.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-btw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Pi extension that adds a /btw side-question command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"directory": "packages/pi-btw"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
46
|
+
"@narumitw/pi-tui-kit": "^0.55.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@earendil-works/pi-ai": "*",
|
package/src/btw.ts
CHANGED
|
@@ -9,10 +9,7 @@ import {
|
|
|
9
9
|
BorderedLoader,
|
|
10
10
|
type ExtensionAPI,
|
|
11
11
|
type ExtensionCommandContext,
|
|
12
|
-
type KeybindingsManager,
|
|
13
|
-
type Theme,
|
|
14
12
|
} from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
16
13
|
import type { MenuContext, RunMenuResult } from "@narumitw/pi-tui-kit";
|
|
17
14
|
import {
|
|
18
15
|
type BtwBringToMainSegment,
|
|
@@ -26,11 +23,13 @@ import {
|
|
|
26
23
|
summarizeBringToMain,
|
|
27
24
|
} from "./bring-to-main.js";
|
|
28
25
|
import { type RunBtwFullscreen, runBtwFullscreen } from "./fullscreen-ui.js";
|
|
26
|
+
import { pickMainEntry } from "./main-tree-picker.js";
|
|
29
27
|
import {
|
|
30
28
|
type BtwCommandMenuResult,
|
|
31
29
|
type BtwResumeThreadSummary,
|
|
32
30
|
runBtwMenuPreservingEditor,
|
|
33
31
|
showBtwCommandMenu,
|
|
32
|
+
showBtwCustomPreservingEditor,
|
|
34
33
|
} from "./menu.js";
|
|
35
34
|
import {
|
|
36
35
|
type BtwSettings,
|
|
@@ -222,6 +221,7 @@ export interface BtwExtensionDependencies {
|
|
|
222
221
|
ctx: ExtensionCommandContext,
|
|
223
222
|
resumeThreads: readonly BtwResumeThreadSummary[],
|
|
224
223
|
) => Promise<BtwCommandMenuResult>;
|
|
224
|
+
pickMainEntry?: typeof pickMainEntry;
|
|
225
225
|
loadSettings?: typeof loadSettingsForCommand;
|
|
226
226
|
resolveModel?: typeof resolveBtwModelWithLoader;
|
|
227
227
|
runThread?: typeof runBtwThread;
|
|
@@ -230,6 +230,7 @@ export interface BtwExtensionDependencies {
|
|
|
230
230
|
|
|
231
231
|
export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
|
|
232
232
|
const showCommandMenu = dependencies.showCommandMenu ?? showCommandMenuForBtw;
|
|
233
|
+
const pickEntry = dependencies.pickMainEntry ?? pickMainEntry;
|
|
233
234
|
const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
|
|
234
235
|
const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
|
|
235
236
|
const runThread = dependencies.runThread ?? runBtwThread;
|
|
@@ -259,9 +260,37 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
let menuResult: BtwCommandMenuResult = "start";
|
|
263
|
+
let selectedConversationContext: string | undefined;
|
|
262
264
|
if (!question) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
+
while (true) {
|
|
266
|
+
menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
|
|
267
|
+
if (menuResult === "closed") return;
|
|
268
|
+
if (menuResult !== "tree") break;
|
|
269
|
+
|
|
270
|
+
const treeResult = await pickEntry(pi, ctx);
|
|
271
|
+
if (treeResult.kind === "closed") return;
|
|
272
|
+
if (treeResult.kind === "back") continue;
|
|
273
|
+
try {
|
|
274
|
+
if (!ctx.sessionManager.getEntry(treeResult.entryId)) {
|
|
275
|
+
notifySafely(ctx, "The selected main-thread entry is no longer available", "warning");
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const branch = ctx.sessionManager.getBranch(treeResult.entryId);
|
|
279
|
+
if (branch.at(-1)?.id !== treeResult.entryId) {
|
|
280
|
+
notifySafely(
|
|
281
|
+
ctx,
|
|
282
|
+
"The selected main-thread branch is no longer available",
|
|
283
|
+
"warning",
|
|
284
|
+
);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
selectedConversationContext = buildConversationContext(branch);
|
|
288
|
+
menuResult = "start";
|
|
289
|
+
break;
|
|
290
|
+
} catch {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
265
294
|
}
|
|
266
295
|
|
|
267
296
|
const settings = await loadSettings(ctx);
|
|
@@ -291,7 +320,8 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
291
320
|
state = {
|
|
292
321
|
id: `btw-${nextThreadNumber}`,
|
|
293
322
|
thread: createSideThread(
|
|
294
|
-
|
|
323
|
+
selectedConversationContext ??
|
|
324
|
+
buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
|
|
295
325
|
),
|
|
296
326
|
thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
|
|
297
327
|
createdAt,
|
|
@@ -546,40 +576,6 @@ export async function runBtwThread({
|
|
|
546
576
|
}
|
|
547
577
|
}
|
|
548
578
|
|
|
549
|
-
type BtwCustomFactory<T> = (
|
|
550
|
-
tui: TUI,
|
|
551
|
-
theme: Theme,
|
|
552
|
-
keybindings: KeybindingsManager,
|
|
553
|
-
done: (result: T) => void,
|
|
554
|
-
) => Component;
|
|
555
|
-
|
|
556
|
-
async function showBtwCustomPreservingEditor<T>(
|
|
557
|
-
ctx: ExtensionCommandContext,
|
|
558
|
-
factory: BtwCustomFactory<T>,
|
|
559
|
-
): Promise<T | undefined> {
|
|
560
|
-
let liveEditorText = ctx.ui.getEditorText();
|
|
561
|
-
let completed = false;
|
|
562
|
-
const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) =>
|
|
563
|
-
factory(tui, theme, keybindings, (value) => {
|
|
564
|
-
try {
|
|
565
|
-
liveEditorText = ctx.ui.getEditorText();
|
|
566
|
-
} catch {
|
|
567
|
-
// Keep completion finite if session replacement invalidates the editor context.
|
|
568
|
-
}
|
|
569
|
-
completed = true;
|
|
570
|
-
done(value);
|
|
571
|
-
}),
|
|
572
|
-
);
|
|
573
|
-
if (completed) {
|
|
574
|
-
try {
|
|
575
|
-
if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
|
|
576
|
-
} catch {
|
|
577
|
-
// A replaced context owns a different editor and must not receive stale restoration.
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
return result;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
579
|
interface ChooseBringToMainDependencies {
|
|
584
580
|
showMenu?: typeof showBtwMenu;
|
|
585
581
|
showPreview?: typeof showBringToMainPreview;
|
package/src/fullscreen-ui.ts
CHANGED
|
@@ -21,7 +21,14 @@ type BtwCustomFactory<T> = (
|
|
|
21
21
|
done: (result: T) => void,
|
|
22
22
|
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>;
|
|
23
23
|
|
|
24
|
-
type BtwFullscreenTui = TUI & {
|
|
24
|
+
type BtwFullscreenTui = TUI & {
|
|
25
|
+
flash?: (message: string, durationMs?: number) => void;
|
|
26
|
+
setLayoutRoot(component: Component | undefined): void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface BtwFullscreenLayoutComponent extends Component {
|
|
30
|
+
getFullscreenLayout(): Component;
|
|
31
|
+
}
|
|
25
32
|
|
|
26
33
|
export type BtwFullscreenTuiFactory = (parent: TUI) => BtwFullscreenTui;
|
|
27
34
|
|
|
@@ -223,6 +230,7 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
223
230
|
let component: (Component & { dispose?(): void }) | undefined;
|
|
224
231
|
let overlay: OverlayHandle | undefined;
|
|
225
232
|
let mounted = false;
|
|
233
|
+
let layoutMounted = false;
|
|
226
234
|
let factorySettled = false;
|
|
227
235
|
let closed = false;
|
|
228
236
|
let promiseSettled = false;
|
|
@@ -243,6 +251,7 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
243
251
|
let cleanupError: unknown;
|
|
244
252
|
try {
|
|
245
253
|
if (overlay) overlay.hide();
|
|
254
|
+
else if (mounted && layoutMounted) fullscreen.setLayoutRoot(undefined);
|
|
246
255
|
else if (mounted && component) fullscreen.removeChild(component);
|
|
247
256
|
} catch (error) {
|
|
248
257
|
cleanupError = error;
|
|
@@ -327,8 +336,13 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
327
336
|
options.onHandle?.(overlay);
|
|
328
337
|
} else {
|
|
329
338
|
fullscreen.clear();
|
|
330
|
-
fullscreen.addChild(component);
|
|
331
339
|
mounted = true;
|
|
340
|
+
if (isFullscreenLayoutComponent(component)) {
|
|
341
|
+
layoutMounted = true;
|
|
342
|
+
fullscreen.setLayoutRoot(component.getFullscreenLayout());
|
|
343
|
+
} else {
|
|
344
|
+
fullscreen.addChild(component);
|
|
345
|
+
}
|
|
332
346
|
fullscreen.setFocus(component);
|
|
333
347
|
fullscreen.requestRender();
|
|
334
348
|
}
|
|
@@ -337,3 +351,9 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
337
351
|
});
|
|
338
352
|
}
|
|
339
353
|
}
|
|
354
|
+
|
|
355
|
+
function isFullscreenLayoutComponent(
|
|
356
|
+
component: Component,
|
|
357
|
+
): component is BtwFullscreenLayoutComponent {
|
|
358
|
+
return "getFullscreenLayout" in component && typeof component.getFullscreenLayout === "function";
|
|
359
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyToClipboard,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionCommandContext,
|
|
5
|
+
type SessionEntry,
|
|
6
|
+
type SessionTreeNode,
|
|
7
|
+
TreeSelectorComponent,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { type Component, type Focusable, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
10
|
+
import { showBtwCustomPreservingEditor } from "./menu.js";
|
|
11
|
+
import { sanitizeSingleLine } from "./text.js";
|
|
12
|
+
|
|
13
|
+
export type MainEntryPickResult =
|
|
14
|
+
| { kind: "selected"; entryId: string }
|
|
15
|
+
| { kind: "back" }
|
|
16
|
+
| { kind: "closed" };
|
|
17
|
+
|
|
18
|
+
export interface MainThreadTreeSelector extends Component, Focusable {
|
|
19
|
+
onCopy?: (text: string | undefined) => void;
|
|
20
|
+
setViewLabel?(entryId: string, label: string | undefined, labelTimestamp?: string): void;
|
|
21
|
+
dispose?(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MainThreadTreeSelectorOptions {
|
|
25
|
+
tree: SessionTreeNode[];
|
|
26
|
+
currentLeafId: string | null;
|
|
27
|
+
terminalRows: number;
|
|
28
|
+
onSelect: (entryId: string) => void;
|
|
29
|
+
onCancel: () => void;
|
|
30
|
+
onCopy: (entryId: string | undefined, displayText: string | undefined) => void;
|
|
31
|
+
onLabelChange: (entryId: string, label: string | undefined) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MainThreadTreePickerDependencies {
|
|
35
|
+
createSelector?: (options: MainThreadTreeSelectorOptions) => MainThreadTreeSelector;
|
|
36
|
+
copyToClipboard?: (text: string, signal: AbortSignal) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class MainThreadTreePickerComponent implements Component, Focusable {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly selector: MainThreadTreeSelector,
|
|
42
|
+
private readonly onClose: () => void,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
get focused(): boolean {
|
|
46
|
+
return this.selector.focused;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set focused(value: boolean) {
|
|
50
|
+
this.selector.focused = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get wantsKeyRelease(): boolean | undefined {
|
|
54
|
+
return this.selector.wantsKeyRelease;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
render(width: number): string[] {
|
|
58
|
+
return this.selector.render(width);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
handleInput(data: string): void {
|
|
62
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
63
|
+
this.onClose();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.selector.handleInput?.(data);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
invalidate(): void {
|
|
70
|
+
this.selector.invalidate();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
dispose(): void {
|
|
74
|
+
this.selector.dispose?.();
|
|
75
|
+
this.onClose();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function pickMainEntry(
|
|
80
|
+
pi: ExtensionAPI,
|
|
81
|
+
ctx: ExtensionCommandContext,
|
|
82
|
+
dependencies: MainThreadTreePickerDependencies = {},
|
|
83
|
+
): Promise<MainEntryPickResult> {
|
|
84
|
+
let rawTree: SessionTreeNode[];
|
|
85
|
+
let currentLeafId: string | null;
|
|
86
|
+
try {
|
|
87
|
+
rawTree = ctx.sessionManager.getTree();
|
|
88
|
+
currentLeafId = ctx.sessionManager.getLeafId();
|
|
89
|
+
} catch {
|
|
90
|
+
return { kind: "closed" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (rawTree.length === 0) {
|
|
94
|
+
notifySafely(ctx, "No main-thread entries are available", "warning");
|
|
95
|
+
return { kind: "back" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tree = sanitizeTreeForDisplay(rawTree);
|
|
99
|
+
const rawCopyText = collectRawCopyText(rawTree);
|
|
100
|
+
const savedLabels = collectSavedLabels(rawTree);
|
|
101
|
+
const createSelector = dependencies.createSelector ?? createNativeTreeSelector;
|
|
102
|
+
const copy = dependencies.copyToClipboard ?? copyText;
|
|
103
|
+
const copyControllers = new Set<AbortController>();
|
|
104
|
+
const copyTasks = new Set<Promise<void>>();
|
|
105
|
+
const abortCopies = () => {
|
|
106
|
+
for (const controller of copyControllers) {
|
|
107
|
+
controller.abort(new Error("The main-thread tree picker closed"));
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const result = await showBtwCustomPreservingEditor<MainEntryPickResult>(
|
|
111
|
+
ctx,
|
|
112
|
+
(tui, _theme, _keybindings, done) => {
|
|
113
|
+
let settled = false;
|
|
114
|
+
let selector: MainThreadTreeSelector | undefined;
|
|
115
|
+
const finish = (value: MainEntryPickResult) => {
|
|
116
|
+
if (settled) return;
|
|
117
|
+
settled = true;
|
|
118
|
+
abortCopies();
|
|
119
|
+
done(value);
|
|
120
|
+
};
|
|
121
|
+
const onCopy = (entryId: string | undefined, displayText: string | undefined) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
const text = entryId ? rawCopyText.get(entryId) : displayText;
|
|
124
|
+
if (!text) {
|
|
125
|
+
notifySafely(ctx, "Selected entry has no text to copy", "warning");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
copyControllers.add(controller);
|
|
130
|
+
let operation: Promise<void>;
|
|
131
|
+
try {
|
|
132
|
+
operation = copy(text, controller.signal);
|
|
133
|
+
} catch (error: unknown) {
|
|
134
|
+
operation = Promise.reject(error);
|
|
135
|
+
}
|
|
136
|
+
let task!: Promise<void>;
|
|
137
|
+
task = operation
|
|
138
|
+
.then(() => {
|
|
139
|
+
if (!settled) notifySafely(ctx, "Copied selected message", "info");
|
|
140
|
+
})
|
|
141
|
+
.catch((error: unknown) => {
|
|
142
|
+
if (!settled && !controller.signal.aborted) {
|
|
143
|
+
notifySafely(ctx, `Could not copy selected message: ${formatError(error)}`, "error");
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
.finally(() => {
|
|
147
|
+
copyControllers.delete(controller);
|
|
148
|
+
copyTasks.delete(task);
|
|
149
|
+
});
|
|
150
|
+
copyTasks.add(task);
|
|
151
|
+
};
|
|
152
|
+
const restoreLabel = (entryId: string) => {
|
|
153
|
+
const previous = savedLabels.get(entryId);
|
|
154
|
+
selector?.setViewLabel?.(entryId, previous?.label, previous?.labelTimestamp);
|
|
155
|
+
tui.requestRender();
|
|
156
|
+
};
|
|
157
|
+
const onLabelChange = (entryId: string, label: string | undefined) => {
|
|
158
|
+
if (settled) return;
|
|
159
|
+
try {
|
|
160
|
+
if (!ctx.sessionManager.getEntry(entryId)) {
|
|
161
|
+
restoreLabel(entryId);
|
|
162
|
+
notifySafely(ctx, "The selected main-thread entry is no longer available", "warning");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const persistedLabel = label === undefined ? undefined : sanitizeSingleLine(label);
|
|
166
|
+
pi.setLabel(entryId, persistedLabel);
|
|
167
|
+
savedLabels.set(entryId, { label: persistedLabel });
|
|
168
|
+
selector?.setViewLabel?.(entryId, persistedLabel);
|
|
169
|
+
tui.requestRender();
|
|
170
|
+
} catch (error: unknown) {
|
|
171
|
+
restoreLabel(entryId);
|
|
172
|
+
notifySafely(ctx, `Could not update tree label: ${formatError(error)}`, "error");
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
selector = createSelector({
|
|
176
|
+
tree,
|
|
177
|
+
currentLeafId,
|
|
178
|
+
terminalRows: tui.terminal.rows,
|
|
179
|
+
onSelect: (entryId) => finish({ kind: "selected", entryId }),
|
|
180
|
+
onCancel: () => finish({ kind: "back" }),
|
|
181
|
+
onCopy,
|
|
182
|
+
onLabelChange,
|
|
183
|
+
});
|
|
184
|
+
return new MainThreadTreePickerComponent(selector, () => finish({ kind: "closed" }));
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
abortCopies();
|
|
188
|
+
await Promise.allSettled([...copyTasks]);
|
|
189
|
+
|
|
190
|
+
return result ?? { kind: "closed" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function createNativeTreeSelector(options: MainThreadTreeSelectorOptions): MainThreadTreeSelector {
|
|
194
|
+
const selector = new TreeSelectorComponent(
|
|
195
|
+
options.tree,
|
|
196
|
+
options.currentLeafId,
|
|
197
|
+
options.terminalRows,
|
|
198
|
+
options.onSelect,
|
|
199
|
+
options.onCancel,
|
|
200
|
+
options.onLabelChange,
|
|
201
|
+
);
|
|
202
|
+
selector.onCopy = (displayText) =>
|
|
203
|
+
options.onCopy(selector.getTreeList().getSelectedNode()?.entry.id, displayText);
|
|
204
|
+
const result = selector as MainThreadTreeSelector;
|
|
205
|
+
result.setViewLabel = (entryId, label, labelTimestamp) =>
|
|
206
|
+
selector.getTreeList().updateNodeLabel(entryId, label, labelTimestamp);
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sanitizeTreeForDisplay(tree: readonly SessionTreeNode[]): SessionTreeNode[] {
|
|
211
|
+
return tree.map((node) => {
|
|
212
|
+
const result: SessionTreeNode = {
|
|
213
|
+
entry: sanitizeEntryForDisplay(node.entry),
|
|
214
|
+
children: sanitizeTreeForDisplay(node.children),
|
|
215
|
+
};
|
|
216
|
+
if (node.label !== undefined) result.label = sanitizeSingleLine(node.label);
|
|
217
|
+
if (node.labelTimestamp !== undefined) {
|
|
218
|
+
result.labelTimestamp = sanitizeSingleLine(node.labelTimestamp);
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sanitizeEntryForDisplay(entry: SessionEntry): SessionEntry {
|
|
225
|
+
switch (entry.type) {
|
|
226
|
+
case "message": {
|
|
227
|
+
const message = { ...entry.message } as Record<string, unknown>;
|
|
228
|
+
if ("content" in entry.message)
|
|
229
|
+
message.content = sanitizeDisplayContent(entry.message.content);
|
|
230
|
+
for (const key of ["role", "errorMessage", "command", "toolName"] as const) {
|
|
231
|
+
const value = message[key];
|
|
232
|
+
if (typeof value === "string") message[key] = sanitizeSingleLine(value);
|
|
233
|
+
}
|
|
234
|
+
return { ...entry, message } as unknown as SessionEntry;
|
|
235
|
+
}
|
|
236
|
+
case "custom_message":
|
|
237
|
+
return {
|
|
238
|
+
...entry,
|
|
239
|
+
customType: sanitizeSingleLine(entry.customType),
|
|
240
|
+
content: sanitizeDisplayContent(entry.content) as typeof entry.content,
|
|
241
|
+
};
|
|
242
|
+
case "compaction":
|
|
243
|
+
return { ...entry, summary: sanitizeSingleLine(entry.summary) };
|
|
244
|
+
case "branch_summary":
|
|
245
|
+
return { ...entry, summary: sanitizeSingleLine(entry.summary) };
|
|
246
|
+
case "model_change":
|
|
247
|
+
return {
|
|
248
|
+
...entry,
|
|
249
|
+
provider: sanitizeSingleLine(entry.provider),
|
|
250
|
+
modelId: sanitizeSingleLine(entry.modelId),
|
|
251
|
+
};
|
|
252
|
+
case "thinking_level_change":
|
|
253
|
+
return { ...entry, thinkingLevel: sanitizeSingleLine(entry.thinkingLevel) };
|
|
254
|
+
case "custom":
|
|
255
|
+
return { ...entry, customType: sanitizeSingleLine(entry.customType) };
|
|
256
|
+
case "label":
|
|
257
|
+
return {
|
|
258
|
+
...entry,
|
|
259
|
+
label: entry.label === undefined ? undefined : sanitizeSingleLine(entry.label),
|
|
260
|
+
};
|
|
261
|
+
case "session_info":
|
|
262
|
+
return {
|
|
263
|
+
...entry,
|
|
264
|
+
name: entry.name === undefined ? undefined : sanitizeSingleLine(entry.name),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sanitizeDisplayContent(content: unknown): unknown {
|
|
270
|
+
if (typeof content === "string") return sanitizeSingleLine(content);
|
|
271
|
+
if (!Array.isArray(content)) return content;
|
|
272
|
+
return content.map((block) => {
|
|
273
|
+
if (block === null || typeof block !== "object" || !("type" in block)) return block;
|
|
274
|
+
if (block.type === "text" && "text" in block && typeof block.text === "string") {
|
|
275
|
+
return { ...block, text: sanitizeSingleLine(block.text) };
|
|
276
|
+
}
|
|
277
|
+
if (block.type === "toolCall") {
|
|
278
|
+
const copy = { ...block } as Record<string, unknown>;
|
|
279
|
+
if (typeof copy.name === "string") copy.name = sanitizeSingleLine(copy.name);
|
|
280
|
+
copy.arguments = sanitizeToolArguments(copy.arguments, new WeakMap());
|
|
281
|
+
return copy;
|
|
282
|
+
}
|
|
283
|
+
return block;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function sanitizeToolArguments(value: unknown, seen: WeakMap<object, unknown>): unknown {
|
|
288
|
+
if (typeof value === "string") return sanitizeSingleLine(value);
|
|
289
|
+
if (value === null || typeof value !== "object") return value;
|
|
290
|
+
const existing = seen.get(value);
|
|
291
|
+
if (existing !== undefined) return existing;
|
|
292
|
+
if (Array.isArray(value)) {
|
|
293
|
+
const result: unknown[] = [];
|
|
294
|
+
seen.set(value, result);
|
|
295
|
+
for (const item of value) result.push(sanitizeToolArguments(item, seen));
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
const result: Record<string, unknown> = {};
|
|
299
|
+
seen.set(value, result);
|
|
300
|
+
for (const [key, item] of Object.entries(value)) {
|
|
301
|
+
result[key] = sanitizeToolArguments(item, seen);
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function collectRawCopyText(tree: readonly SessionTreeNode[]): Map<string, string> {
|
|
307
|
+
const result = new Map<string, string>();
|
|
308
|
+
const visit = (nodes: readonly SessionTreeNode[]) => {
|
|
309
|
+
for (const node of nodes) {
|
|
310
|
+
const text = getRawCopyText(node.entry);
|
|
311
|
+
if (text !== undefined) result.set(node.entry.id, text);
|
|
312
|
+
visit(node.children);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
visit(tree);
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getRawCopyText(entry: SessionEntry): string | undefined {
|
|
320
|
+
let text: string | undefined;
|
|
321
|
+
if (entry.type === "message") {
|
|
322
|
+
if (entry.message.role === "bashExecution") text = entry.message.command;
|
|
323
|
+
else if ("content" in entry.message) {
|
|
324
|
+
text = extractRawText(entry.message.content);
|
|
325
|
+
if (!text && entry.message.role === "assistant") text = entry.message.errorMessage;
|
|
326
|
+
}
|
|
327
|
+
} else if (entry.type === "custom_message") text = extractRawText(entry.content);
|
|
328
|
+
else if (entry.type === "compaction" || entry.type === "branch_summary") text = entry.summary;
|
|
329
|
+
return text?.trim() ? text : undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function extractRawText(content: unknown): string {
|
|
333
|
+
if (typeof content === "string") return content;
|
|
334
|
+
if (!Array.isArray(content)) return "";
|
|
335
|
+
return content
|
|
336
|
+
.filter(
|
|
337
|
+
(block): block is { type: "text"; text: string } =>
|
|
338
|
+
block !== null &&
|
|
339
|
+
typeof block === "object" &&
|
|
340
|
+
"type" in block &&
|
|
341
|
+
block.type === "text" &&
|
|
342
|
+
"text" in block &&
|
|
343
|
+
typeof block.text === "string",
|
|
344
|
+
)
|
|
345
|
+
.map((block) => block.text)
|
|
346
|
+
.join("");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
interface SavedLabel {
|
|
350
|
+
label: string | undefined;
|
|
351
|
+
labelTimestamp?: string;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function collectSavedLabels(tree: readonly SessionTreeNode[]): Map<string, SavedLabel> {
|
|
355
|
+
const result = new Map<string, SavedLabel>();
|
|
356
|
+
const visit = (nodes: readonly SessionTreeNode[]) => {
|
|
357
|
+
for (const node of nodes) {
|
|
358
|
+
result.set(node.entry.id, {
|
|
359
|
+
label: node.label === undefined ? undefined : sanitizeSingleLine(node.label),
|
|
360
|
+
labelTimestamp:
|
|
361
|
+
node.labelTimestamp === undefined ? undefined : sanitizeSingleLine(node.labelTimestamp),
|
|
362
|
+
});
|
|
363
|
+
visit(node.children);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
visit(tree);
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function copyText(text: string, signal: AbortSignal): Promise<void> {
|
|
371
|
+
signal.throwIfAborted();
|
|
372
|
+
await copyToClipboard(text);
|
|
373
|
+
signal.throwIfAborted();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function notifySafely(
|
|
377
|
+
ctx: ExtensionCommandContext,
|
|
378
|
+
message: string,
|
|
379
|
+
level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
|
|
380
|
+
): void {
|
|
381
|
+
try {
|
|
382
|
+
ctx.ui.notify(sanitizeSingleLine(message), level);
|
|
383
|
+
} catch {
|
|
384
|
+
// The command context may have been replaced while the selector was open.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function formatError(error: unknown): string {
|
|
389
|
+
return error instanceof Error ? error.message : String(error);
|
|
390
|
+
}
|
package/src/menu.ts
CHANGED
|
@@ -41,10 +41,14 @@ export interface ShowBtwCommandMenuOptions {
|
|
|
41
41
|
) => Promise<BtwSettings>;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export type BtwCommandMenuResult =
|
|
44
|
+
export type BtwCommandMenuResult =
|
|
45
|
+
| "start"
|
|
46
|
+
| "tree"
|
|
47
|
+
| "closed"
|
|
48
|
+
| { kind: "resume"; threadId: string };
|
|
45
49
|
|
|
46
50
|
type BtwMenuScreen = "main" | "resume" | "settings" | "invalid";
|
|
47
|
-
type BtwMenuAction = "start" | "resume" | "set-thinking" | "set-remember";
|
|
51
|
+
type BtwMenuAction = "start" | "start-tree" | "resume" | "set-thinking" | "set-remember";
|
|
48
52
|
const SAME_AS_MAIN_THREAD = "Same as main thread";
|
|
49
53
|
type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
|
|
50
54
|
|
|
@@ -72,6 +76,7 @@ export async function showBtwCommandMenu(
|
|
|
72
76
|
const displaySettingsPath = sanitizeSingleLine(settingsPath);
|
|
73
77
|
const resumeThreads = options.resumeThreads ?? [];
|
|
74
78
|
let startSelected = false;
|
|
79
|
+
let treeSelected = false;
|
|
75
80
|
let resumedThreadId: string | undefined;
|
|
76
81
|
|
|
77
82
|
const loadState = async (): Promise<BtwMenuState> => {
|
|
@@ -114,6 +119,12 @@ export async function showBtwCommandMenu(
|
|
|
114
119
|
description: "Open an empty side thread",
|
|
115
120
|
action: "start",
|
|
116
121
|
},
|
|
122
|
+
{
|
|
123
|
+
id: "start-tree",
|
|
124
|
+
label: "Start from main thread tree…",
|
|
125
|
+
description: "Choose context without switching the main branch",
|
|
126
|
+
action: "start-tree",
|
|
127
|
+
},
|
|
117
128
|
...(resumeThreads.length > 0
|
|
118
129
|
? [
|
|
119
130
|
{
|
|
@@ -184,6 +195,10 @@ export async function showBtwCommandMenu(
|
|
|
184
195
|
startSelected = true;
|
|
185
196
|
return { kind: "close" };
|
|
186
197
|
},
|
|
198
|
+
"start-tree": async () => {
|
|
199
|
+
treeSelected = true;
|
|
200
|
+
return { kind: "close" };
|
|
201
|
+
},
|
|
187
202
|
resume: async ({ itemId }: { itemId: string }) => {
|
|
188
203
|
if (!resumeThreads.some((thread) => thread.id === itemId)) {
|
|
189
204
|
return { kind: "rejected" } as const;
|
|
@@ -233,9 +248,37 @@ export async function showBtwCommandMenu(
|
|
|
233
248
|
);
|
|
234
249
|
if (result.kind !== "closed" || result.reason !== "close") return "closed";
|
|
235
250
|
if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
|
|
251
|
+
if (treeSelected) return "tree";
|
|
236
252
|
return startSelected ? "start" : "closed";
|
|
237
253
|
}
|
|
238
254
|
|
|
255
|
+
export async function showBtwCustomPreservingEditor<T>(
|
|
256
|
+
ctx: ExtensionCommandContext,
|
|
257
|
+
factory: BtwCustomFactory<T>,
|
|
258
|
+
): Promise<T | undefined> {
|
|
259
|
+
let liveEditorText = ctx.ui.getEditorText();
|
|
260
|
+
let completed = false;
|
|
261
|
+
const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) =>
|
|
262
|
+
factory(tui, theme, keybindings, (value) => {
|
|
263
|
+
try {
|
|
264
|
+
liveEditorText = ctx.ui.getEditorText();
|
|
265
|
+
} catch {
|
|
266
|
+
// Keep completion finite if session replacement invalidates the editor context.
|
|
267
|
+
}
|
|
268
|
+
completed = true;
|
|
269
|
+
done(value);
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
if (completed) {
|
|
273
|
+
try {
|
|
274
|
+
if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
|
|
275
|
+
} catch {
|
|
276
|
+
// A replaced context owns a different editor and must not receive stale restoration.
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
239
282
|
export async function runBtwMenuPreservingEditor(
|
|
240
283
|
ctx: ExtensionCommandContext,
|
|
241
284
|
run: (menuContext: MenuContext) => Promise<RunMenuResult>,
|
package/src/transcript-pager.ts
CHANGED
|
@@ -16,10 +16,13 @@ import {
|
|
|
16
16
|
Loader,
|
|
17
17
|
Markdown,
|
|
18
18
|
matchesKey,
|
|
19
|
+
ScrollView,
|
|
19
20
|
type TUI,
|
|
20
21
|
truncateToWidth,
|
|
22
|
+
VStack,
|
|
21
23
|
visibleWidth,
|
|
22
24
|
} from "@earendil-works/pi-tui";
|
|
25
|
+
import type { BtwFullscreenLayoutComponent } from "./fullscreen-ui.js";
|
|
23
26
|
import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
|
|
24
27
|
import { sanitizeSingleLine } from "./text.js";
|
|
25
28
|
|
|
@@ -29,6 +32,21 @@ const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;
|
|
|
29
32
|
// Pi renders a spacer above the custom component and a two-line built-in footer below it.
|
|
30
33
|
const RESERVED_APP_LINES = 3;
|
|
31
34
|
|
|
35
|
+
// A temporary fit after manual scrolling must not silently resume following new output.
|
|
36
|
+
class PreservingScrollView extends ScrollView {
|
|
37
|
+
override updateLayout(
|
|
38
|
+
contentHeight: number,
|
|
39
|
+
viewportHeight: number,
|
|
40
|
+
requestRender: () => void,
|
|
41
|
+
): void {
|
|
42
|
+
const preserveManualPosition = !this.isFollowingEnd;
|
|
43
|
+
super.updateLayout(contentHeight, viewportHeight, requestRender);
|
|
44
|
+
if (preserveManualPosition && this.isFollowingEnd) {
|
|
45
|
+
this.scrollTo(this.scrollTop, { disableFollow: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
export type TranscriptPagerAction =
|
|
33
51
|
| { kind: "submit"; question: string }
|
|
34
52
|
| { kind: "bringToMain"; questionDraft: string }
|
|
@@ -49,14 +67,13 @@ export interface BtwAnsweringViewOptions {
|
|
|
49
67
|
};
|
|
50
68
|
}
|
|
51
69
|
|
|
52
|
-
export class BtwTranscriptPager implements
|
|
70
|
+
export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusable {
|
|
53
71
|
private readonly transcriptComponents: Component[];
|
|
54
72
|
private readonly editor: Editor;
|
|
55
73
|
private readonly canBringToMain: boolean;
|
|
56
|
-
private
|
|
74
|
+
private readonly scrollView: ScrollView;
|
|
75
|
+
private readonly layoutRoot: VStack;
|
|
57
76
|
private lastContentLineCount = 0;
|
|
58
|
-
private lastViewportHeight = 1;
|
|
59
|
-
private followBottom: boolean;
|
|
60
77
|
private warning: string | undefined;
|
|
61
78
|
private finished = false;
|
|
62
79
|
private isFocused = false;
|
|
@@ -75,7 +92,6 @@ export class BtwTranscriptPager implements Component, Focusable {
|
|
|
75
92
|
) {
|
|
76
93
|
this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
|
|
77
94
|
this.canBringToMain = turns.some((turn) => turn.kind === "answered");
|
|
78
|
-
this.followBottom = options.startAtBottom ?? false;
|
|
79
95
|
this.thinkingLevel = options.thinking?.level;
|
|
80
96
|
const editorTheme: EditorTheme = {
|
|
81
97
|
borderColor: (text) => this.theme.fg("accent", text),
|
|
@@ -101,6 +117,17 @@ export class BtwTranscriptPager implements Component, Focusable {
|
|
|
101
117
|
this.finished = true;
|
|
102
118
|
this.onAction({ kind: "submit", question });
|
|
103
119
|
};
|
|
120
|
+
const transcript = this.createTranscriptComponent();
|
|
121
|
+
this.scrollView = new PreservingScrollView(transcript, {
|
|
122
|
+
follow: options.startAtBottom ? "end" : "none",
|
|
123
|
+
primary: true,
|
|
124
|
+
});
|
|
125
|
+
this.layoutRoot = new VStack([
|
|
126
|
+
{ component: this.createHeaderComponent(), basis: 1, shrink: 0, minSize: 1 },
|
|
127
|
+
{ component: this.scrollView, basis: 0, grow: 1, minSize: 0 },
|
|
128
|
+
{ component: this.createFooterComponent(), basis: 1, shrink: 0, minSize: 1 },
|
|
129
|
+
{ component: this.editor, basis: "auto", shrink: 1, minSize: 0 },
|
|
130
|
+
]);
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
get focused(): boolean {
|
|
@@ -112,6 +139,10 @@ export class BtwTranscriptPager implements Component, Focusable {
|
|
|
112
139
|
this.editor.focused = value;
|
|
113
140
|
}
|
|
114
141
|
|
|
142
|
+
getFullscreenLayout(): Component {
|
|
143
|
+
return this.layoutRoot;
|
|
144
|
+
}
|
|
145
|
+
|
|
115
146
|
render(width: number): string[] {
|
|
116
147
|
const safeWidth = Math.max(1, width);
|
|
117
148
|
const editorLines = this.editor.render(safeWidth);
|
|
@@ -122,13 +153,13 @@ export class BtwTranscriptPager implements Component, Focusable {
|
|
|
122
153
|
);
|
|
123
154
|
const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
|
|
124
155
|
this.lastContentLineCount = contentLines.length;
|
|
125
|
-
this.
|
|
126
|
-
|
|
127
|
-
|
|
156
|
+
this.scrollView.updateLayout(contentLines.length, viewportHeight, () =>
|
|
157
|
+
this.tui.requestRender(),
|
|
158
|
+
);
|
|
128
159
|
|
|
129
160
|
return fitComposerLayout(
|
|
130
161
|
renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
|
|
131
|
-
contentLines.slice(this.
|
|
162
|
+
contentLines.slice(this.scrollView.scrollTop, this.scrollView.scrollTop + viewportHeight),
|
|
132
163
|
this.renderFooter(safeWidth),
|
|
133
164
|
editorLines,
|
|
134
165
|
availableRows,
|
|
@@ -164,15 +195,12 @@ export class BtwTranscriptPager implements Component, Focusable {
|
|
|
164
195
|
return;
|
|
165
196
|
}
|
|
166
197
|
if (matchesKey(data, Key.pageUp)) {
|
|
167
|
-
|
|
168
|
-
this.scrollBy(-this.lastViewportHeight);
|
|
169
|
-
if (this.scrollOffset < previousOffset) this.followBottom = false;
|
|
198
|
+
this.scrollView.scrollBy(-Math.max(1, this.scrollView.viewportHeight));
|
|
170
199
|
this.tui.requestRender();
|
|
171
200
|
return;
|
|
172
201
|
}
|
|
173
202
|
if (matchesKey(data, Key.pageDown)) {
|
|
174
|
-
this.scrollBy(this.
|
|
175
|
-
this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
|
|
203
|
+
this.scrollView.scrollBy(Math.max(1, this.scrollView.viewportHeight));
|
|
176
204
|
this.tui.requestRender();
|
|
177
205
|
return;
|
|
178
206
|
}
|
|
@@ -181,8 +209,7 @@ export class BtwTranscriptPager implements Component, Focusable {
|
|
|
181
209
|
}
|
|
182
210
|
|
|
183
211
|
invalidate(): void {
|
|
184
|
-
|
|
185
|
-
this.editor.invalidate();
|
|
212
|
+
this.layoutRoot.invalidate();
|
|
186
213
|
}
|
|
187
214
|
|
|
188
215
|
dispose(): void {
|
|
@@ -218,7 +245,7 @@ export class BtwTranscriptPager implements Component, Focusable {
|
|
|
218
245
|
? compactBase
|
|
219
246
|
: fallbackBase;
|
|
220
247
|
if (scrollable) {
|
|
221
|
-
const history = ` • ${this.
|
|
248
|
+
const history = ` • ${this.scrollView.scrollTop > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
|
|
222
249
|
const compactHistory = " • PgUp/PgDn";
|
|
223
250
|
const compactScrollable = this.canBringToMain
|
|
224
251
|
? "Enter • Ctrl+R • Ctrl+C • PgUp/PgDn"
|
|
@@ -238,29 +265,46 @@ export class BtwTranscriptPager implements Component, Focusable {
|
|
|
238
265
|
return truncateToWidth(this.theme.fg("muted", hints), width);
|
|
239
266
|
}
|
|
240
267
|
|
|
241
|
-
private
|
|
242
|
-
|
|
243
|
-
|
|
268
|
+
private createHeaderComponent(): Component {
|
|
269
|
+
return {
|
|
270
|
+
render: (width) => [renderSideThreadHeader(width, this.theme, this.thinkingLevel)],
|
|
271
|
+
invalidate() {},
|
|
272
|
+
};
|
|
244
273
|
}
|
|
245
274
|
|
|
246
|
-
private
|
|
247
|
-
|
|
275
|
+
private createTranscriptComponent(): Component {
|
|
276
|
+
return {
|
|
277
|
+
render: (width) => {
|
|
278
|
+
const lines = renderTranscriptLines(this.transcriptComponents, width);
|
|
279
|
+
this.lastContentLineCount = lines.length;
|
|
280
|
+
return lines;
|
|
281
|
+
},
|
|
282
|
+
invalidate: () => {
|
|
283
|
+
for (const component of this.transcriptComponents) component.invalidate();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private createFooterComponent(): Component {
|
|
289
|
+
return {
|
|
290
|
+
render: (width) => [this.renderFooter(width)],
|
|
291
|
+
invalidate() {},
|
|
292
|
+
};
|
|
248
293
|
}
|
|
249
294
|
|
|
250
295
|
private getMaxScrollOffset(): number {
|
|
251
|
-
return Math.max(0, this.lastContentLineCount - this.
|
|
296
|
+
return Math.max(0, this.lastContentLineCount - this.scrollView.viewportHeight);
|
|
252
297
|
}
|
|
253
298
|
}
|
|
254
299
|
|
|
255
|
-
export class BtwAnsweringView implements
|
|
300
|
+
export class BtwAnsweringView implements BtwFullscreenLayoutComponent, Focusable {
|
|
256
301
|
private readonly transcriptComponents: Component[];
|
|
257
302
|
private readonly loader: Loader;
|
|
258
303
|
private readonly editor: Editor | undefined;
|
|
259
304
|
private readonly controller = new AbortController();
|
|
260
|
-
private
|
|
305
|
+
private readonly scrollView: ScrollView;
|
|
306
|
+
private readonly layoutRoot: VStack;
|
|
261
307
|
private lastContentLineCount = 0;
|
|
262
|
-
private lastViewportHeight = 1;
|
|
263
|
-
private followBottom = true;
|
|
264
308
|
private warning: string | undefined;
|
|
265
309
|
private finished = false;
|
|
266
310
|
private isFocused = false;
|
|
@@ -308,6 +352,23 @@ export class BtwAnsweringView implements Component, Focusable {
|
|
|
308
352
|
this.warning = undefined;
|
|
309
353
|
};
|
|
310
354
|
}
|
|
355
|
+
const transcript = this.createTranscriptComponent();
|
|
356
|
+
this.scrollView = new PreservingScrollView(transcript, { follow: "end", primary: true });
|
|
357
|
+
this.layoutRoot = new VStack([
|
|
358
|
+
{ component: this.createHeaderComponent(), basis: 1, shrink: 0, minSize: 1 },
|
|
359
|
+
{ component: this.scrollView, basis: 0, grow: 1, minSize: 0 },
|
|
360
|
+
{
|
|
361
|
+
component: this.createSteeringComponent(),
|
|
362
|
+
basis: "auto",
|
|
363
|
+
shrink: 1,
|
|
364
|
+
minSize: 0,
|
|
365
|
+
maxSize: MAX_STEERING_DISPLAY_LINES,
|
|
366
|
+
},
|
|
367
|
+
{ component: this.createFooterComponent(), basis: 1, shrink: 0, minSize: 1 },
|
|
368
|
+
...(this.editor
|
|
369
|
+
? [{ component: this.editor, basis: "auto" as const, shrink: 1, minSize: 0 }]
|
|
370
|
+
: []),
|
|
371
|
+
]);
|
|
311
372
|
}
|
|
312
373
|
|
|
313
374
|
get focused(): boolean {
|
|
@@ -323,6 +384,10 @@ export class BtwAnsweringView implements Component, Focusable {
|
|
|
323
384
|
return this.controller.signal;
|
|
324
385
|
}
|
|
325
386
|
|
|
387
|
+
getFullscreenLayout(): Component {
|
|
388
|
+
return this.layoutRoot;
|
|
389
|
+
}
|
|
390
|
+
|
|
326
391
|
render(width: number): string[] {
|
|
327
392
|
const safeWidth = Math.max(1, width);
|
|
328
393
|
const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
|
|
@@ -343,13 +408,13 @@ export class BtwAnsweringView implements Component, Focusable {
|
|
|
343
408
|
);
|
|
344
409
|
const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
|
|
345
410
|
this.lastContentLineCount = contentLines.length;
|
|
346
|
-
this.
|
|
347
|
-
|
|
348
|
-
|
|
411
|
+
this.scrollView.updateLayout(contentLines.length, viewportHeight, () =>
|
|
412
|
+
this.tui.requestRender(),
|
|
413
|
+
);
|
|
349
414
|
|
|
350
415
|
return fitComposerLayout(
|
|
351
416
|
renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
|
|
352
|
-
contentLines.slice(this.
|
|
417
|
+
contentLines.slice(this.scrollView.scrollTop, this.scrollView.scrollTop + viewportHeight),
|
|
353
418
|
this.renderFooter(safeWidth),
|
|
354
419
|
editorLines,
|
|
355
420
|
availableRows,
|
|
@@ -383,15 +448,12 @@ export class BtwAnsweringView implements Component, Focusable {
|
|
|
383
448
|
return;
|
|
384
449
|
}
|
|
385
450
|
if (matchesKey(data, Key.pageUp)) {
|
|
386
|
-
|
|
387
|
-
this.scrollBy(-this.lastViewportHeight);
|
|
388
|
-
if (this.scrollOffset < previousOffset) this.followBottom = false;
|
|
451
|
+
this.scrollView.scrollBy(-Math.max(1, this.scrollView.viewportHeight));
|
|
389
452
|
this.tui.requestRender();
|
|
390
453
|
return;
|
|
391
454
|
}
|
|
392
455
|
if (matchesKey(data, Key.pageDown)) {
|
|
393
|
-
this.scrollBy(this.
|
|
394
|
-
this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
|
|
456
|
+
this.scrollView.scrollBy(Math.max(1, this.scrollView.viewportHeight));
|
|
395
457
|
this.tui.requestRender();
|
|
396
458
|
return;
|
|
397
459
|
}
|
|
@@ -400,9 +462,7 @@ export class BtwAnsweringView implements Component, Focusable {
|
|
|
400
462
|
}
|
|
401
463
|
|
|
402
464
|
invalidate(): void {
|
|
403
|
-
|
|
404
|
-
this.loader.invalidate();
|
|
405
|
-
this.editor?.invalidate();
|
|
465
|
+
this.layoutRoot.invalidate();
|
|
406
466
|
}
|
|
407
467
|
|
|
408
468
|
finish(): void {
|
|
@@ -442,17 +502,48 @@ export class BtwAnsweringView implements Component, Focusable {
|
|
|
442
502
|
return truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", selectedHints)}`, width);
|
|
443
503
|
}
|
|
444
504
|
|
|
445
|
-
private
|
|
446
|
-
|
|
447
|
-
|
|
505
|
+
private createHeaderComponent(): Component {
|
|
506
|
+
return {
|
|
507
|
+
render: (width) => [renderSideThreadHeader(width, this.theme, this.thinkingLevel)],
|
|
508
|
+
invalidate() {},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private createTranscriptComponent(): Component {
|
|
513
|
+
return {
|
|
514
|
+
render: (width) => {
|
|
515
|
+
const lines = renderTranscriptLines(this.transcriptComponents, width);
|
|
516
|
+
this.lastContentLineCount = lines.length;
|
|
517
|
+
return lines;
|
|
518
|
+
},
|
|
519
|
+
invalidate: () => {
|
|
520
|
+
for (const component of this.transcriptComponents) component.invalidate();
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private createSteeringComponent(): Component {
|
|
526
|
+
return {
|
|
527
|
+
render: (width) =>
|
|
528
|
+
renderSteeringLines(
|
|
529
|
+
this.options.steering?.questions ?? [],
|
|
530
|
+
width,
|
|
531
|
+
this.theme,
|
|
532
|
+
MAX_STEERING_DISPLAY_LINES,
|
|
533
|
+
),
|
|
534
|
+
invalidate() {},
|
|
535
|
+
};
|
|
448
536
|
}
|
|
449
537
|
|
|
450
|
-
private
|
|
451
|
-
|
|
538
|
+
private createFooterComponent(): Component {
|
|
539
|
+
return {
|
|
540
|
+
render: (width) => [this.renderFooter(width)],
|
|
541
|
+
invalidate: () => this.loader.invalidate(),
|
|
542
|
+
};
|
|
452
543
|
}
|
|
453
544
|
|
|
454
545
|
private getMaxScrollOffset(): number {
|
|
455
|
-
return Math.max(0, this.lastContentLineCount - this.
|
|
546
|
+
return Math.max(0, this.lastContentLineCount - this.scrollView.viewportHeight);
|
|
456
547
|
}
|
|
457
548
|
}
|
|
458
549
|
|