@narumitw/pi-btw 0.31.0 → 0.34.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 CHANGED
@@ -11,6 +11,7 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
11
11
  - Adds a `/btw` side-thread command to Pi, with an optional initial question.
12
12
  - Answers side questions in a temporary, scrollable UI.
13
13
  - Supports follow-up questions in the same ephemeral side thread.
14
+ - Optionally brings the latest answer, a question-to-end suffix, an exact line range, or the entire side thread into the main editor.
14
15
  - Uses the current session branch as context.
15
16
  - Uses Pi's current model or an independent model selected in `pi-btw.json`.
16
17
  - Inherits Pi's current thinking level or uses a fixed level from `pi-btw.json`.
@@ -62,9 +63,33 @@ required.
62
63
  Previous side questions and answers remain available to the model and visible for that
63
64
  invocation. While a response is running, the transcript stays visible above a compact
64
65
  `Answering…` status. The footer shows `PgUp`/`PgDn` only when history can scroll; press
65
- `Ctrl+C` to cancel an in-progress answer or leave the side thread. Closing it, reloading Pi,
66
- or switching sessions discards it without adding any of its questions or answers to the main
67
- conversation.
66
+ `Ctrl+C` to cancel an in-progress answer or leave the side thread.
67
+
68
+ After at least one successful answer, press `Ctrl+R` to bring selected context to the main
69
+ editor. The scope menu shows the size of the latest question and answer and the entire side
70
+ thread before you choose. Bring the latest question and answer, everything from a chosen
71
+ question onward, an exact text range, or the entire side thread. Question-suffix, exact-range,
72
+ and entire-thread choices preview the exact editable context block before the side thread closes;
73
+ `Escape` returns and `Ctrl+C` closes without bringing anything to main.
74
+
75
+ The text-range selector supports both fast line selection and editor-style character selection.
76
+ It reports whether anything is selected plus the selected line, message, and approximate token
77
+ counts. Press `Space` to select the current raw source line, then use `Up`/`Down` to extend by
78
+ whole lines; press `Space` again to clear it. Alternatively, use the arrow keys to move the cursor
79
+ and `Shift`+arrow keys to extend a character-level selection. Starting a Shift selection replaces
80
+ any active line selection. Selected lines include a visible `●` marker in addition to highlighting.
81
+ Pi's configured keys control vertical navigation, bringing, and going back (`Up`/`Down`, `Enter`,
82
+ and `Escape` by default), and the selector displays the active keys. Selection follows raw source
83
+ text rather than terminal-wrapped visual rows.
84
+
85
+ Bringing context to main closes the side thread and loads a deterministic, editable context block
86
+ into Pi's main editor. It never sends the draft automatically. If the main editor already has a
87
+ draft, append is the recommended default. Replace is labeled as destructive and requires a second
88
+ confirmation; Cancel returns to the side thread without changing either draft. Concurrent editor
89
+ updates made while these menus are open are preserved. A success message reports whether context
90
+ was loaded, appended, or replaced and its approximate size. Without an explicit bring-to-main
91
+ action, closing `/btw`, reloading Pi, or switching sessions still discards the side thread without
92
+ adding it to the main conversation.
68
93
 
69
94
  ## ⚙️ Model and thinking level
70
95
 
@@ -114,7 +139,10 @@ Normal assistant messages become part of the main Pi conversation and can distra
114
139
  extensions/pi-btw/
115
140
  ├── src/
116
141
  │ ├── index.ts
117
- └── btw.ts
142
+ ├── btw.ts
143
+ │ ├── bring-to-main.ts
144
+ │ ├── side-thread.ts
145
+ │ └── transcript-pager.ts
118
146
  ├── README.md
119
147
  ├── LICENSE
120
148
  ├── tsconfig.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.31.0",
3
+ "version": "0.34.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,870 @@
1
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Key,
5
+ matchesKey,
6
+ type TUI,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import type { SideThreadTurn } from "./side-thread.js";
11
+
12
+ const RESERVED_APP_ROWS = 3;
13
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
14
+
15
+ export interface BtwBringToMainSegment {
16
+ role: "user" | "assistant";
17
+ text: string;
18
+ }
19
+
20
+ export interface BtwBringToMainSummary {
21
+ lines: number;
22
+ messages: number;
23
+ tokens: number;
24
+ }
25
+
26
+ export interface BtwSelectionLine {
27
+ role: BtwBringToMainSegment["role"];
28
+ text: string;
29
+ }
30
+
31
+ export interface BtwTextPosition {
32
+ line: number;
33
+ column: number;
34
+ }
35
+
36
+ export interface BtwTextRangeSelectorState {
37
+ cursor: BtwTextPosition;
38
+ anchor?: BtwTextPosition;
39
+ lineAnchor?: number;
40
+ preferredColumn: number;
41
+ scrollOffset: number;
42
+ horizontalOffset: number;
43
+ }
44
+
45
+ export type BtwQuickBringToMainScope =
46
+ | { kind: "latest" }
47
+ | { kind: "from"; answeredTurnIndex: number }
48
+ | { kind: "entire" };
49
+
50
+ export type BtwMenuSelectorAction =
51
+ | { kind: "select"; value: string }
52
+ | { kind: "back" }
53
+ | { kind: "close" };
54
+
55
+ export type BtwTextRangeSelectorAction =
56
+ | { kind: "confirm"; segments: BtwBringToMainSegment[] }
57
+ | { kind: "back" }
58
+ | { kind: "close" };
59
+
60
+ export function getAnsweredTurns(
61
+ turns: readonly SideThreadTurn[],
62
+ ): Array<Extract<SideThreadTurn, { kind: "answered" }>> {
63
+ return turns.filter(
64
+ (turn): turn is Extract<SideThreadTurn, { kind: "answered" }> => turn.kind === "answered",
65
+ );
66
+ }
67
+
68
+ export function buildQuickBringToMainSegments(
69
+ turns: readonly SideThreadTurn[],
70
+ scope: BtwQuickBringToMainScope,
71
+ ): BtwBringToMainSegment[] {
72
+ const answered = getAnsweredTurns(turns);
73
+ const selected =
74
+ scope.kind === "latest"
75
+ ? answered.slice(-1)
76
+ : scope.kind === "from"
77
+ ? answered.slice(Math.max(0, scope.answeredTurnIndex))
78
+ : answered;
79
+ return selected.flatMap((turn) => [
80
+ { role: "user" as const, text: turn.question },
81
+ { role: "assistant" as const, text: turn.answer },
82
+ ]);
83
+ }
84
+
85
+ export function buildBtwSelectionLines(turns: readonly SideThreadTurn[]): BtwSelectionLine[] {
86
+ return buildQuickBringToMainSegments(turns, { kind: "entire" }).flatMap((segment) =>
87
+ segment.text.split("\n").map((text) => ({ role: segment.role, text })),
88
+ );
89
+ }
90
+
91
+ export function segmentsFromLineRange(
92
+ lines: readonly BtwSelectionLine[],
93
+ anchor: number,
94
+ cursor: number,
95
+ ): BtwBringToMainSegment[] {
96
+ if (lines.length === 0) return [];
97
+ const start = Math.max(0, Math.min(anchor, cursor, lines.length - 1));
98
+ const end = Math.max(0, Math.min(Math.max(anchor, cursor), lines.length - 1));
99
+ const segments: BtwBringToMainSegment[] = [];
100
+ for (const line of lines.slice(start, end + 1)) {
101
+ const previous = segments.at(-1);
102
+ if (previous?.role === line.role) {
103
+ previous.text += `\n${line.text}`;
104
+ } else {
105
+ segments.push({ role: line.role, text: line.text });
106
+ }
107
+ }
108
+ return segments;
109
+ }
110
+
111
+ export function segmentsFromTextRange(
112
+ lines: readonly BtwSelectionLine[],
113
+ anchor: BtwTextPosition,
114
+ cursor: BtwTextPosition,
115
+ ): BtwBringToMainSegment[] {
116
+ if (lines.length === 0) return [];
117
+ const first = clampTextPosition(lines, anchor);
118
+ const second = clampTextPosition(lines, cursor);
119
+ const [start, end] = compareTextPositions(first, second) <= 0 ? [first, second] : [second, first];
120
+ if (compareTextPositions(start, end) === 0) return [];
121
+
122
+ const segments: BtwBringToMainSegment[] = [];
123
+ for (let lineIndex = start.line; lineIndex <= end.line; lineIndex += 1) {
124
+ const line = lines[lineIndex];
125
+ if (!line) continue;
126
+ const characters = splitGraphemes(line.text);
127
+ const from = lineIndex === start.line ? start.column : 0;
128
+ const to = lineIndex === end.line ? end.column : characters.length;
129
+ const text = characters.slice(from, to).join("");
130
+ if (text) {
131
+ const previous = segments.at(-1);
132
+ if (previous?.role === line.role) previous.text += text;
133
+ else segments.push({ role: line.role, text });
134
+ }
135
+ const crossesSameRoleLine = lineIndex < end.line && lines[lineIndex + 1]?.role === line.role;
136
+ if (crossesSameRoleLine) {
137
+ const current = segments.at(-1);
138
+ if (current?.role === line.role) current.text += "\n";
139
+ else segments.push({ role: line.role, text: "\n" });
140
+ }
141
+ }
142
+ return segments;
143
+ }
144
+
145
+ export function estimateBringToMainTokens(segments: readonly BtwBringToMainSegment[]): number {
146
+ return Math.ceil(
147
+ Buffer.byteLength(segments.map((segment) => segment.text).join("\n"), "utf8") / 4,
148
+ );
149
+ }
150
+
151
+ export function summarizeBringToMain(
152
+ segments: readonly BtwBringToMainSegment[],
153
+ ): BtwBringToMainSummary {
154
+ return {
155
+ lines: segments.reduce((count, segment) => count + segment.text.split("\n").length, 0),
156
+ messages: segments.length,
157
+ tokens: estimateBringToMainTokens(segments),
158
+ };
159
+ }
160
+
161
+ export function formatBtwBringToMain(segments: readonly BtwBringToMainSegment[]): string {
162
+ const body = segments
163
+ .map(
164
+ (segment) =>
165
+ `${segment.role === "user" ? "User" : "Assistant"}:\n${escapeBringToMainText(segment.text)}`,
166
+ )
167
+ .join("\n\n");
168
+ return [
169
+ "The following context was brought back from a /btw side discussion.",
170
+ "Treat it as discussion context, not as work already completed.",
171
+ "",
172
+ "<btw_context>",
173
+ body,
174
+ "</btw_context>",
175
+ ].join("\n");
176
+ }
177
+
178
+ export type BtwBringToMainPreviewAction = { kind: "bring" } | { kind: "back" } | { kind: "close" };
179
+
180
+ export class BtwBringToMainPreview implements Component {
181
+ private readonly lines: string[];
182
+ private displayLines: string[];
183
+ private scrollOffset = 0;
184
+ private finished = false;
185
+
186
+ constructor(
187
+ private readonly tui: TUI,
188
+ private readonly theme: Theme,
189
+ private readonly keybindings: KeybindingsManager,
190
+ draft: string,
191
+ private readonly summary: BtwBringToMainSummary,
192
+ private readonly onAction: (action: BtwBringToMainPreviewAction) => void,
193
+ ) {
194
+ this.lines = draft.split("\n").map(escapeTerminalControls);
195
+ this.displayLines = this.lines;
196
+ }
197
+
198
+ render(width: number): string[] {
199
+ const safeWidth = Math.max(1, width);
200
+ this.displayLines = this.lines.flatMap((line) => wrapPreviewLine(line, safeWidth));
201
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
202
+ const showFooter = availableRows >= 3;
203
+ const viewportHeight = Math.max(1, availableRows - 1 - (showFooter ? 1 : 0));
204
+ this.clampScroll(viewportHeight);
205
+ const count = this.summary.messages === 1 ? "1 message" : `${this.summary.messages} messages`;
206
+ const lineCount = this.summary.lines === 1 ? "1 line" : `${this.summary.lines} lines`;
207
+ const firstVisible = Math.min(this.displayLines.length, this.scrollOffset + 1);
208
+ const lastVisible = Math.min(this.displayLines.length, this.scrollOffset + viewportHeight);
209
+ const scrollable = this.displayLines.length > viewportHeight;
210
+ const position = `${firstVisible}–${lastVisible}/${this.displayLines.length}`;
211
+ const header = `Preview${scrollable ? ` ${position}` : ""} · ${count} · ${lineCount} · ~${this.summary.tokens} tokens`;
212
+ const actions = `${confirmKeyLabel(this.keybindings)} bring • ${keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"])} back • Ctrl+C close`;
213
+ const scroll = `${position} • ${keybindingLabel(this.keybindings, "tui.select.pageUp")}/${keybindingLabel(this.keybindings, "tui.select.pageDown")} scroll`;
214
+ const detailedFooter = scrollable ? `${scroll} • ${actions}` : actions;
215
+ const footer = visibleWidth(detailedFooter) <= safeWidth ? detailedFooter : actions;
216
+ return fitRows(
217
+ [
218
+ truncateToWidth(this.theme.fg("accent", this.theme.bold(header)), safeWidth, ""),
219
+ ...this.displayLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
220
+ ...(showFooter ? [truncateToWidth(this.theme.fg("muted", footer), safeWidth, "")] : []),
221
+ ],
222
+ availableRows,
223
+ );
224
+ }
225
+
226
+ handleInput(data: string): void {
227
+ if (this.finished) return;
228
+ if (matchesKey(data, Key.ctrl("c"))) {
229
+ this.finish({ kind: "close" });
230
+ return;
231
+ }
232
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
233
+ this.finish({ kind: "back" });
234
+ return;
235
+ }
236
+ if (matchesConfirm(data, this.keybindings)) {
237
+ this.finish({ kind: "bring" });
238
+ return;
239
+ }
240
+ if (this.keybindings.matches(data, "tui.select.pageUp")) {
241
+ this.scrollOffset -= Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS - 2);
242
+ this.clampScroll(Math.max(0, this.tui.terminal.rows - RESERVED_APP_ROWS - 2));
243
+ this.tui.requestRender();
244
+ return;
245
+ }
246
+ if (this.keybindings.matches(data, "tui.select.pageDown")) {
247
+ this.scrollOffset += Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS - 2);
248
+ this.clampScroll(Math.max(0, this.tui.terminal.rows - RESERVED_APP_ROWS - 2));
249
+ this.tui.requestRender();
250
+ }
251
+ }
252
+
253
+ invalidate(): void {}
254
+
255
+ private clampScroll(viewportHeight: number): void {
256
+ this.scrollOffset = Math.max(
257
+ 0,
258
+ Math.min(this.scrollOffset, Math.max(0, this.displayLines.length - viewportHeight)),
259
+ );
260
+ }
261
+
262
+ private finish(action: BtwBringToMainPreviewAction): void {
263
+ if (this.finished) return;
264
+ this.finished = true;
265
+ this.onAction(action);
266
+ }
267
+ }
268
+
269
+ export class BtwMenuSelector implements Component {
270
+ private cursor = 0;
271
+ private scrollOffset = 0;
272
+ private finished = false;
273
+
274
+ constructor(
275
+ private readonly tui: TUI,
276
+ private readonly theme: Theme,
277
+ private readonly keybindings: KeybindingsManager,
278
+ private readonly title: string,
279
+ private readonly options: readonly string[],
280
+ private readonly onAction: (action: BtwMenuSelectorAction) => void,
281
+ initialValue?: string,
282
+ ) {
283
+ const initialIndex = initialValue === undefined ? -1 : options.indexOf(initialValue);
284
+ if (initialIndex >= 0) this.cursor = initialIndex;
285
+ }
286
+
287
+ render(width: number): string[] {
288
+ const safeWidth = Math.max(1, width);
289
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
290
+ const showFooter = availableRows >= 3;
291
+ const viewportHeight = Math.max(1, availableRows - 1 - (showFooter ? 1 : 0));
292
+ this.keepCursorVisible(viewportHeight);
293
+ const rows = this.options
294
+ .slice(this.scrollOffset, this.scrollOffset + viewportHeight)
295
+ .map((option, visibleIndex) => {
296
+ const index = this.scrollOffset + visibleIndex;
297
+ const raw = `${index === this.cursor ? ">" : " "} ${escapeTerminalControls(option)}`;
298
+ const toned = option.startsWith("⚠") ? this.theme.fg("warning", raw) : raw;
299
+ const styled =
300
+ index === this.cursor ? this.theme.bg("selectedBg", this.theme.fg("text", toned)) : toned;
301
+ return truncateToWidth(styled, safeWidth, "");
302
+ });
303
+ return fitRows(
304
+ [
305
+ truncateToWidth(
306
+ this.theme.fg("accent", this.theme.bold(escapeTerminalControls(this.title))),
307
+ safeWidth,
308
+ "",
309
+ ),
310
+ ...rows,
311
+ ...(showFooter
312
+ ? [
313
+ truncateToWidth(
314
+ this.theme.fg(
315
+ "muted",
316
+ `${confirmKeyLabel(this.keybindings)} confirm • ${keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"])} back • Ctrl+C close`,
317
+ ),
318
+ safeWidth,
319
+ "",
320
+ ),
321
+ ]
322
+ : []),
323
+ ],
324
+ availableRows,
325
+ );
326
+ }
327
+
328
+ handleInput(data: string): void {
329
+ if (this.finished) return;
330
+ if (matchesKey(data, Key.ctrl("c"))) {
331
+ this.finish({ kind: "close" });
332
+ return;
333
+ }
334
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
335
+ this.finish({ kind: "back" });
336
+ return;
337
+ }
338
+ if (matchesConfirm(data, this.keybindings)) {
339
+ const value = this.options[this.cursor];
340
+ if (value !== undefined) this.finish({ kind: "select", value });
341
+ return;
342
+ }
343
+ if (this.keybindings.matches(data, "tui.select.up")) {
344
+ this.cursor = Math.max(0, this.cursor - 1);
345
+ this.tui.requestRender();
346
+ return;
347
+ }
348
+ if (this.keybindings.matches(data, "tui.select.down")) {
349
+ this.cursor = Math.min(Math.max(0, this.options.length - 1), this.cursor + 1);
350
+ this.tui.requestRender();
351
+ return;
352
+ }
353
+ if (this.keybindings.matches(data, "tui.select.pageUp")) {
354
+ this.cursor = Math.max(0, this.cursor - 10);
355
+ this.tui.requestRender();
356
+ return;
357
+ }
358
+ if (this.keybindings.matches(data, "tui.select.pageDown")) {
359
+ this.cursor = Math.min(Math.max(0, this.options.length - 1), this.cursor + 10);
360
+ this.tui.requestRender();
361
+ return;
362
+ }
363
+ }
364
+
365
+ invalidate(): void {}
366
+
367
+ private keepCursorVisible(height: number): void {
368
+ if (height <= 0) return;
369
+ if (this.cursor < this.scrollOffset) this.scrollOffset = this.cursor;
370
+ if (this.cursor >= this.scrollOffset + height) {
371
+ this.scrollOffset = this.cursor - height + 1;
372
+ }
373
+ }
374
+
375
+ private finish(action: BtwMenuSelectorAction): void {
376
+ if (this.finished) return;
377
+ this.finished = true;
378
+ this.onAction(action);
379
+ }
380
+ }
381
+
382
+ export class BtwTextRangeSelector implements Component {
383
+ private readonly lines: BtwSelectionLine[];
384
+ private cursor: BtwTextPosition = { line: 0, column: 0 };
385
+ private anchor: BtwTextPosition | undefined;
386
+ private lineAnchor: number | undefined;
387
+ private preferredColumn = 0;
388
+ private scrollOffset = 0;
389
+ private horizontalOffset = 0;
390
+ private warning: string | undefined;
391
+ private finished = false;
392
+
393
+ constructor(
394
+ private readonly tui: TUI,
395
+ private readonly theme: Theme,
396
+ private readonly keybindings: KeybindingsManager,
397
+ turns: readonly SideThreadTurn[],
398
+ private readonly onAction: (action: BtwTextRangeSelectorAction) => void,
399
+ initialState?: BtwTextRangeSelectorState,
400
+ ) {
401
+ this.lines = buildBtwSelectionLines(turns);
402
+ if (initialState) {
403
+ this.cursor = clampTextPosition(this.lines, initialState.cursor);
404
+ this.anchor = initialState.anchor
405
+ ? clampTextPosition(this.lines, initialState.anchor)
406
+ : undefined;
407
+ this.lineAnchor =
408
+ initialState.lineAnchor === undefined
409
+ ? undefined
410
+ : Math.max(0, Math.min(this.lines.length - 1, initialState.lineAnchor));
411
+ this.preferredColumn = Math.max(0, initialState.preferredColumn);
412
+ this.scrollOffset = Math.max(0, initialState.scrollOffset);
413
+ this.horizontalOffset = Math.max(0, initialState.horizontalOffset);
414
+ }
415
+ }
416
+
417
+ getState(): BtwTextRangeSelectorState {
418
+ return {
419
+ cursor: { ...this.cursor },
420
+ anchor: this.anchor ? { ...this.anchor } : undefined,
421
+ lineAnchor: this.lineAnchor,
422
+ preferredColumn: this.preferredColumn,
423
+ scrollOffset: this.scrollOffset,
424
+ horizontalOffset: this.horizontalOffset,
425
+ };
426
+ }
427
+
428
+ render(width: number): string[] {
429
+ const safeWidth = Math.max(1, width);
430
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
431
+ const showStatus = availableRows >= 4;
432
+ const showFooter = availableRows >= 3;
433
+ const viewportHeight = Math.max(
434
+ 1,
435
+ availableRows - 1 - (showStatus ? 1 : 0) - (showFooter ? 1 : 0),
436
+ );
437
+ this.keepCursorVisible(viewportHeight);
438
+ const textWidth = Math.max(1, safeWidth - visibleWidth("●> Assistant │ "));
439
+ this.keepCursorHorizontallyVisible(textWidth);
440
+ const range = this.getSelectionRange();
441
+ const lineRange = this.getLineSelectionRange();
442
+ const visible = this.lines.slice(this.scrollOffset, this.scrollOffset + viewportHeight);
443
+ const rows = visible.map((line, visibleIndex) => {
444
+ const lineIndex = this.scrollOffset + visibleIndex;
445
+ const role = line.role === "user" ? "User" : "Assistant";
446
+ const lineSelected = lineRange
447
+ ? lineIndex >= lineRange.start && lineIndex <= lineRange.end
448
+ : false;
449
+ const prefix = `${lineSelected ? "●" : " "}${lineIndex === this.cursor.line ? ">" : " "} ${role.padEnd(9)} │ `;
450
+ const text = this.renderTextLine(line, lineIndex, range, lineSelected);
451
+ return truncateToWidth(
452
+ lineIndex === this.cursor.line ? this.theme.fg("accent", prefix) + text : prefix + text,
453
+ safeWidth,
454
+ "",
455
+ );
456
+ });
457
+ const selected = this.getSelectedSegments();
458
+ const summary = summarizeBringToMain(selected);
459
+ const status =
460
+ selected.length === 0
461
+ ? "Selected: none"
462
+ : `Selected: ${summary.lines} ${summary.lines === 1 ? "line" : "lines"} · ${summary.messages} ${summary.messages === 1 ? "message" : "messages"} · ~${summary.tokens} ${summary.tokens === 1 ? "token" : "tokens"}`;
463
+ const confirm = confirmKeyLabel(this.keybindings);
464
+ const back = keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"]);
465
+ const vertical = `${keybindingLabel(this.keybindings, "tui.select.up")}/${keybindingLabel(this.keybindings, "tui.select.down")}`;
466
+ const confirmUsesSpace = this.keybindings.matches(" ", "tui.select.confirm");
467
+ const detailedFooter = this.warning
468
+ ? `${this.warning} • ${confirmUsesSpace ? "Shift+Arrows select" : "Space lines • Shift+Arrows text"} • ${back} back • Ctrl+C close`
469
+ : this.lineAnchor !== undefined
470
+ ? `${confirmUsesSpace ? `${vertical} extend lines` : `Space clear • ${vertical} extend lines`} • Shift+Arrows text • ${confirm} bring • ${back} back • Ctrl+C close`
471
+ : `Shift+Arrows select • Arrows move${confirmUsesSpace ? "" : " • Space lines"} • ${confirm} bring • ${back} back • Ctrl+C close`;
472
+ const criticalFooter = `${confirm} bring • ${back} back • Ctrl+C close`;
473
+ const footer = visibleWidth(detailedFooter) <= safeWidth ? detailedFooter : criticalFooter;
474
+ return fitRows(
475
+ [
476
+ truncateToWidth(
477
+ this.theme.fg("accent", this.theme.bold("Select text to bring to main")),
478
+ safeWidth,
479
+ "",
480
+ ),
481
+ ...(showStatus ? [truncateToWidth(this.theme.fg("muted", status), safeWidth, "")] : []),
482
+ ...rows,
483
+ ...(showFooter
484
+ ? [
485
+ truncateToWidth(
486
+ this.theme.fg(this.warning ? "warning" : "muted", footer),
487
+ safeWidth,
488
+ "",
489
+ ),
490
+ ]
491
+ : []),
492
+ ],
493
+ availableRows,
494
+ );
495
+ }
496
+
497
+ handleInput(data: string): void {
498
+ if (this.finished) return;
499
+ if (matchesKey(data, Key.ctrl("c"))) {
500
+ this.finish({ kind: "close" });
501
+ return;
502
+ }
503
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
504
+ this.finish({ kind: "back" });
505
+ return;
506
+ }
507
+ if (matchesConfirm(data, this.keybindings)) {
508
+ if (this.lines.length > 0) {
509
+ const segments = this.getSelectedSegments();
510
+ if (segments.length === 0) {
511
+ this.warning = "Select text first";
512
+ this.tui.requestRender();
513
+ } else {
514
+ this.finish({ kind: "confirm", segments });
515
+ }
516
+ }
517
+ return;
518
+ }
519
+ if (matchesKey(data, Key.space)) {
520
+ this.anchor = undefined;
521
+ this.lineAnchor = this.lineAnchor === undefined ? this.cursor.line : undefined;
522
+ this.afterMove();
523
+ return;
524
+ }
525
+ if (matchesKey(data, Key.shift("left"))) {
526
+ this.moveHorizontal(-1, true);
527
+ return;
528
+ }
529
+ if (matchesKey(data, Key.shift("right"))) {
530
+ this.moveHorizontal(1, true);
531
+ return;
532
+ }
533
+ if (matchesKey(data, Key.shift("up"))) {
534
+ this.moveVertical(-1, true);
535
+ return;
536
+ }
537
+ if (matchesKey(data, Key.shift("down"))) {
538
+ this.moveVertical(1, true);
539
+ return;
540
+ }
541
+ if (matchesKey(data, Key.left)) {
542
+ this.moveHorizontal(-1, false);
543
+ return;
544
+ }
545
+ if (matchesKey(data, Key.right)) {
546
+ this.moveHorizontal(1, false);
547
+ return;
548
+ }
549
+ if (this.keybindings.matches(data, "tui.select.up")) {
550
+ this.moveVertical(-1, false);
551
+ return;
552
+ }
553
+ if (this.keybindings.matches(data, "tui.select.down")) {
554
+ this.moveVertical(1, false);
555
+ return;
556
+ }
557
+ if (this.keybindings.matches(data, "tui.select.pageUp")) {
558
+ this.moveVertical(-10, false);
559
+ return;
560
+ }
561
+ if (this.keybindings.matches(data, "tui.select.pageDown")) {
562
+ this.moveVertical(10, false);
563
+ return;
564
+ }
565
+ }
566
+
567
+ invalidate(): void {}
568
+
569
+ private renderTextLine(
570
+ line: BtwSelectionLine,
571
+ lineIndex: number,
572
+ range: { start: BtwTextPosition; end: BtwTextPosition } | undefined,
573
+ lineSelected: boolean,
574
+ ): string {
575
+ const characters = splitGraphemes(line.text);
576
+ let rendered = this.horizontalOffset > 0 ? this.theme.fg("muted", "…") : "";
577
+ let buffer = "";
578
+ let bufferSelected = false;
579
+ const flush = () => {
580
+ if (!buffer) return;
581
+ rendered += bufferSelected
582
+ ? this.theme.bg("selectedBg", this.theme.fg("text", buffer))
583
+ : buffer;
584
+ buffer = "";
585
+ };
586
+ for (let column = this.horizontalOffset; column <= characters.length; column += 1) {
587
+ if (range && lineIndex === range.start.line && column === range.start.column) {
588
+ flush();
589
+ rendered += this.theme.fg("accent", "[");
590
+ }
591
+ if (lineIndex === this.cursor.line && column === this.cursor.column) {
592
+ flush();
593
+ rendered += this.theme.fg("accent", "│");
594
+ }
595
+ if (range && lineIndex === range.end.line && column === range.end.column) {
596
+ flush();
597
+ rendered += this.theme.fg("accent", "]");
598
+ }
599
+ const character = characters[column];
600
+ if (character === undefined) continue;
601
+ const selected =
602
+ lineSelected || (range ? positionFallsInside(lineIndex, column, range) : false);
603
+ if (buffer && selected !== bufferSelected) flush();
604
+ bufferSelected = selected;
605
+ buffer += escapeTerminalControls(character);
606
+ }
607
+ flush();
608
+ return rendered;
609
+ }
610
+
611
+ private getSelectionRange(): { start: BtwTextPosition; end: BtwTextPosition } | undefined {
612
+ if (!this.anchor || compareTextPositions(this.anchor, this.cursor) === 0) return undefined;
613
+ return compareTextPositions(this.anchor, this.cursor) < 0
614
+ ? { start: this.anchor, end: this.cursor }
615
+ : { start: this.cursor, end: this.anchor };
616
+ }
617
+
618
+ private getLineSelectionRange(): { start: number; end: number } | undefined {
619
+ return this.lineAnchor === undefined
620
+ ? undefined
621
+ : {
622
+ start: Math.min(this.lineAnchor, this.cursor.line),
623
+ end: Math.max(this.lineAnchor, this.cursor.line),
624
+ };
625
+ }
626
+
627
+ private getSelectedSegments(): BtwBringToMainSegment[] {
628
+ if (this.lineAnchor !== undefined) {
629
+ return segmentsFromLineRange(this.lines, this.lineAnchor, this.cursor.line);
630
+ }
631
+ return this.anchor ? segmentsFromTextRange(this.lines, this.anchor, this.cursor) : [];
632
+ }
633
+
634
+ private moveHorizontal(delta: -1 | 1, extend: boolean): void {
635
+ if (this.lines.length === 0) return;
636
+ if (!extend) this.lineAnchor = undefined;
637
+ if (!extend && this.anchor) {
638
+ const range = this.getSelectionRange();
639
+ if (range) this.cursor = delta < 0 ? range.start : range.end;
640
+ this.anchor = undefined;
641
+ this.preferredColumn = this.cursor.column;
642
+ this.afterMove();
643
+ return;
644
+ }
645
+ this.beginOrClearSelection(extend);
646
+ const line = this.lines[this.cursor.line];
647
+ const length = line ? splitGraphemes(line.text).length : 0;
648
+ if (delta < 0) {
649
+ if (this.cursor.column > 0) this.cursor = { ...this.cursor, column: this.cursor.column - 1 };
650
+ else if (this.cursor.line > 0) {
651
+ const previousLine = this.lines[this.cursor.line - 1];
652
+ this.cursor = {
653
+ line: this.cursor.line - 1,
654
+ column: previousLine ? splitGraphemes(previousLine.text).length : 0,
655
+ };
656
+ }
657
+ } else if (this.cursor.column < length) {
658
+ this.cursor = { ...this.cursor, column: this.cursor.column + 1 };
659
+ } else if (this.cursor.line < this.lines.length - 1) {
660
+ this.cursor = { line: this.cursor.line + 1, column: 0 };
661
+ }
662
+ this.preferredColumn = this.cursor.column;
663
+ this.afterMove();
664
+ }
665
+
666
+ private moveVertical(delta: number, extend: boolean): void {
667
+ if (this.lines.length === 0) return;
668
+ if (extend || this.lineAnchor === undefined) this.beginOrClearSelection(extend);
669
+ const line = Math.max(0, Math.min(this.lines.length - 1, this.cursor.line + delta));
670
+ const target = this.lines[line];
671
+ this.cursor = {
672
+ line,
673
+ column: Math.min(this.preferredColumn, target ? splitGraphemes(target.text).length : 0),
674
+ };
675
+ this.afterMove();
676
+ }
677
+
678
+ private beginOrClearSelection(extend: boolean): void {
679
+ if (extend) this.lineAnchor = undefined;
680
+ if (extend && !this.anchor) this.anchor = { ...this.cursor };
681
+ if (!extend) this.anchor = undefined;
682
+ }
683
+
684
+ private afterMove(): void {
685
+ this.warning = undefined;
686
+ this.tui.requestRender();
687
+ }
688
+
689
+ private keepCursorVisible(height: number): void {
690
+ if (height <= 0) return;
691
+ if (this.cursor.line < this.scrollOffset) this.scrollOffset = this.cursor.line;
692
+ if (this.cursor.line >= this.scrollOffset + height) {
693
+ this.scrollOffset = this.cursor.line - height + 1;
694
+ }
695
+ }
696
+
697
+ private keepCursorHorizontallyVisible(width: number): void {
698
+ const characters = splitGraphemes(this.lines[this.cursor.line]?.text ?? "");
699
+ const displayWidths = characters.map((character) =>
700
+ visibleWidth(escapeTerminalControls(character)),
701
+ );
702
+ const currentWidth = displayWidths[this.cursor.column] ?? 0;
703
+ let usedWidth = 1 + Math.min(currentWidth, Math.max(0, width - 1));
704
+ let offset = this.cursor.column;
705
+ for (let index = this.cursor.column - 1; index >= 0; index -= 1) {
706
+ const nextWidth = usedWidth + (displayWidths[index] ?? 0) + (index > 0 ? 1 : 0);
707
+ if (nextWidth > width) break;
708
+ usedWidth += displayWidths[index] ?? 0;
709
+ offset = index;
710
+ }
711
+ this.horizontalOffset = offset;
712
+ }
713
+
714
+ private finish(action: BtwTextRangeSelectorAction): void {
715
+ if (this.finished) return;
716
+ this.finished = true;
717
+ this.onAction(action);
718
+ }
719
+ }
720
+
721
+ function clampTextPosition(
722
+ lines: readonly BtwSelectionLine[],
723
+ position: BtwTextPosition,
724
+ ): BtwTextPosition {
725
+ const line = Math.max(0, Math.min(lines.length - 1, position.line));
726
+ const text = lines[line]?.text ?? "";
727
+ return {
728
+ line,
729
+ column: Math.max(0, Math.min(splitGraphemes(text).length, position.column)),
730
+ };
731
+ }
732
+
733
+ function confirmKeyLabel(keybindings: KeybindingsManager): string {
734
+ return keybindingLabel(keybindings, "tui.select.confirm", ["ctrl+c"], "enter");
735
+ }
736
+
737
+ function matchesConfirm(data: string, keybindings: KeybindingsManager): boolean {
738
+ if (matchesKey(data, Key.ctrl("c"))) return false;
739
+ const hasUsableBinding = keybindings
740
+ .getKeys("tui.select.confirm")
741
+ .map(String)
742
+ .some((key) => key.toLowerCase() !== "ctrl+c");
743
+ return (
744
+ keybindings.matches(data, "tui.select.confirm") ||
745
+ (!hasUsableBinding && matchesKey(data, Key.enter))
746
+ );
747
+ }
748
+
749
+ function keybindingLabel(
750
+ keybindings: KeybindingsManager,
751
+ keybinding:
752
+ | "tui.select.confirm"
753
+ | "tui.select.cancel"
754
+ | "tui.select.up"
755
+ | "tui.select.down"
756
+ | "tui.select.pageUp"
757
+ | "tui.select.pageDown",
758
+ excluded: readonly string[] = [],
759
+ fallback?: string,
760
+ ): string {
761
+ const key = keybindings
762
+ .getKeys(keybinding)
763
+ .map(String)
764
+ .find((candidate) => !excluded.includes(candidate.toLowerCase()));
765
+ return formatKeyLabel(key ?? fallback ?? keybinding);
766
+ }
767
+
768
+ function formatKeyLabel(key: string): string {
769
+ return key
770
+ .split("+")
771
+ .map((part) => {
772
+ const lower = part.toLowerCase();
773
+ if (lower === "ctrl") return "Ctrl";
774
+ if (lower === "alt") return "Alt";
775
+ if (lower === "shift") return "Shift";
776
+ if (lower === "escape" || lower === "esc") return "Esc";
777
+ if (lower === "enter" || lower === "return") return "Enter";
778
+ if (lower === "pageup") return "PgUp";
779
+ if (lower === "pagedown") return "PgDn";
780
+ return part.length === 1
781
+ ? part.toUpperCase()
782
+ : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
783
+ })
784
+ .join("+");
785
+ }
786
+
787
+ function splitGraphemes(text: string): string[] {
788
+ return [...GRAPHEME_SEGMENTER.segment(text)].map(({ segment }) => segment);
789
+ }
790
+
791
+ function wrapPreviewLine(text: string, width: number): string[] {
792
+ if (!text) return [""];
793
+ const rows: string[] = [];
794
+ let row = "";
795
+ let rowWidth = 0;
796
+ const append = (value: string, valueWidth: number) => {
797
+ if (row && rowWidth + valueWidth > width) {
798
+ rows.push(row);
799
+ row = "";
800
+ rowWidth = 0;
801
+ }
802
+ row += value;
803
+ rowWidth += valueWidth;
804
+ };
805
+ for (const character of splitGraphemes(text)) {
806
+ const characterWidth = visibleWidth(character);
807
+ if (characterWidth <= width) {
808
+ append(character, characterWidth);
809
+ continue;
810
+ }
811
+ const codePoints = [...character]
812
+ .map((value) => `\\u{${value.codePointAt(0)?.toString(16) ?? "0"}}`)
813
+ .join("");
814
+ for (const value of codePoints) append(value, 1);
815
+ }
816
+ rows.push(row);
817
+ return rows;
818
+ }
819
+
820
+ function compareTextPositions(first: BtwTextPosition, second: BtwTextPosition): number {
821
+ return first.line === second.line ? first.column - second.column : first.line - second.line;
822
+ }
823
+
824
+ function positionFallsInside(
825
+ line: number,
826
+ column: number,
827
+ range: { start: BtwTextPosition; end: BtwTextPosition },
828
+ ): boolean {
829
+ const position = { line, column };
830
+ return (
831
+ compareTextPositions(position, range.start) >= 0 &&
832
+ compareTextPositions(position, range.end) < 0
833
+ );
834
+ }
835
+
836
+ function fitRows(rows: string[], availableRows: number): string[] {
837
+ if (rows.length <= availableRows) return rows;
838
+ if (availableRows <= 1) return rows.slice(0, 1);
839
+ return [rows[0] ?? "", ...rows.slice(rows.length - availableRows + 1)];
840
+ }
841
+
842
+ function escapeBringToMainText(text: string): string {
843
+ return [...text]
844
+ .map((character) => {
845
+ if (character === "\n") return character;
846
+ if (character === "\t") return " ";
847
+ const code = character.charCodeAt(0);
848
+ if (code <= 31 || (code >= 127 && code <= 159)) {
849
+ return `\\x${code.toString(16).padStart(2, "0")}`;
850
+ }
851
+ return character;
852
+ })
853
+ .join("")
854
+ .replace(/<btw_context(?=[ \t\r\n>])/g, "&lt;btw_context")
855
+ .replace(/<\/btw_context[ \t\r\n]*>/g, (terminator) =>
856
+ terminator.replaceAll("<", "&lt;").replaceAll(">", "&gt;"),
857
+ );
858
+ }
859
+
860
+ function escapeTerminalControls(text: string): string {
861
+ return [...text]
862
+ .map((character) => {
863
+ const code = character.charCodeAt(0);
864
+ if (code <= 31 || (code >= 127 && code <= 159)) {
865
+ return `\\x${code.toString(16).padStart(2, "0")}`;
866
+ }
867
+ return character;
868
+ })
869
+ .join("");
870
+ }
package/src/btw.ts CHANGED
@@ -6,7 +6,25 @@ import {
6
6
  type ExtensionAPI,
7
7
  type ExtensionCommandContext,
8
8
  getAgentDir,
9
+ type KeybindingsManager,
10
+ type Theme,
9
11
  } from "@earendil-works/pi-coding-agent";
12
+ import type { Component, TUI } from "@earendil-works/pi-tui";
13
+ import {
14
+ BtwBringToMainPreview,
15
+ type BtwBringToMainPreviewAction,
16
+ type BtwBringToMainSegment,
17
+ type BtwBringToMainSummary,
18
+ BtwMenuSelector,
19
+ type BtwMenuSelectorAction,
20
+ BtwTextRangeSelector,
21
+ type BtwTextRangeSelectorState,
22
+ buildQuickBringToMainSegments,
23
+ estimateBringToMainTokens,
24
+ formatBtwBringToMain,
25
+ getAnsweredTurns,
26
+ summarizeBringToMain,
27
+ } from "./bring-to-main.js";
10
28
  import {
11
29
  BTW_THINKING_LEVELS,
12
30
  type BtwThinkingLevel,
@@ -285,8 +303,24 @@ async function resolveBtwModelWithLoader(
285
303
  interface RunBtwThreadDependencies {
286
304
  ask?: typeof askThreadQuestion;
287
305
  interact?: typeof showThreadComposer;
306
+ chooseBringToMain?: typeof chooseBringToMain;
307
+ deliverBringToMain?: typeof loadBringToMainDraft;
288
308
  }
289
309
 
310
+ export type BtwThreadResult = { kind: "closed" };
311
+
312
+ type BtwBringToMainChoice =
313
+ | BtwThreadResult
314
+ | {
315
+ kind: "bringToMain";
316
+ draft: string;
317
+ summary: BtwBringToMainSummary;
318
+ selectionState?: BtwTextRangeSelectorState;
319
+ }
320
+ | { kind: "back" };
321
+
322
+ type BtwBringToMainDelivery = "loaded" | "back" | "closed";
323
+
290
324
  interface RunBtwThreadOptions {
291
325
  initialQuestion?: string;
292
326
  selected: ResolvedBtwModel;
@@ -301,23 +335,39 @@ export async function runBtwThread({
301
335
  thinkingLevel,
302
336
  ctx,
303
337
  dependencies = {},
304
- }: RunBtwThreadOptions): Promise<void> {
338
+ }: RunBtwThreadOptions): Promise<BtwThreadResult> {
305
339
  const ask = dependencies.ask ?? askThreadQuestion;
306
340
  const interact = dependencies.interact ?? showThreadComposer;
341
+ const chooseBringToMainAction = dependencies.chooseBringToMain ?? chooseBringToMain;
342
+ const deliverBringToMainDraft = dependencies.deliverBringToMain ?? loadBringToMainDraft;
307
343
  const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
308
344
  let pendingQuestion = initialQuestion;
345
+ let composerDraft: string | undefined;
309
346
 
310
347
  while (true) {
311
348
  if (!pendingQuestion) {
312
- const action = await interact(thread, thread.turns.length > 0, ctx);
313
- if (action.kind === "close") return;
349
+ const action = await interact(thread, thread.turns.length > 0, ctx, composerDraft);
350
+ if (action.kind === "close") return { kind: "closed" };
351
+ if (action.kind === "bringToMain") {
352
+ const choice = await chooseBringToMainAction(thread, ctx);
353
+ if (choice.kind === "closed") return choice;
354
+ if (choice.kind === "back") {
355
+ composerDraft = action.questionDraft;
356
+ continue;
357
+ }
358
+ const delivery = await deliverBringToMainDraft(choice.draft, ctx, choice.summary);
359
+ if (delivery === "loaded" || delivery === "closed") return { kind: "closed" };
360
+ composerDraft = action.questionDraft;
361
+ continue;
362
+ }
363
+ composerDraft = undefined;
314
364
  pendingQuestion = action.question;
315
365
  }
316
366
 
317
367
  const result = await ask(thread, pendingQuestion, selected, thinkingLevel, ctx);
318
368
  if (result.kind === "aborted") {
319
369
  ctx.ui.notify("Cancelled", "info");
320
- return;
370
+ return { kind: "closed" };
321
371
  }
322
372
  if (result.kind === "error") {
323
373
  thread.turns.push({
@@ -331,6 +381,236 @@ export async function runBtwThread({
331
381
  }
332
382
  }
333
383
 
384
+ type BtwCustomFactory<T> = (
385
+ tui: TUI,
386
+ theme: Theme,
387
+ keybindings: KeybindingsManager,
388
+ done: (result: T) => void,
389
+ ) => Component;
390
+
391
+ async function showBtwCustomPreservingEditor<T>(
392
+ ctx: ExtensionCommandContext,
393
+ factory: BtwCustomFactory<T>,
394
+ ): Promise<T> {
395
+ let liveEditorText = ctx.ui.getEditorText();
396
+ const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) =>
397
+ factory(tui, theme, keybindings, (value) => {
398
+ liveEditorText = ctx.ui.getEditorText();
399
+ done(value);
400
+ }),
401
+ );
402
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
403
+ return result;
404
+ }
405
+
406
+ interface ChooseBringToMainDependencies {
407
+ showMenu?: typeof showBtwMenu;
408
+ showPreview?: typeof showBringToMainPreview;
409
+ }
410
+
411
+ export async function chooseBringToMain(
412
+ thread: SideThread,
413
+ ctx: ExtensionCommandContext,
414
+ dependencies: ChooseBringToMainDependencies = {},
415
+ ): Promise<BtwBringToMainChoice> {
416
+ const answered = getAnsweredTurns(thread.turns);
417
+ if (answered.length === 0) return { kind: "back" };
418
+ const showMenu = dependencies.showMenu ?? showBtwMenu;
419
+ const showPreview = dependencies.showPreview ?? showBringToMainPreview;
420
+ const makeChoice = (segments: readonly BtwBringToMainSegment[]) => ({
421
+ kind: "bringToMain" as const,
422
+ draft: formatBtwBringToMain(segments),
423
+ summary: summarizeBringToMain(segments),
424
+ });
425
+
426
+ const latestSegments = buildQuickBringToMainSegments(thread.turns, { kind: "latest" });
427
+ const entireSegments = buildQuickBringToMainSegments(thread.turns, { kind: "entire" });
428
+ const latestOption = `Latest question and answer 1 Q&A · ~${estimateBringToMainTokens(latestSegments)} tokens`;
429
+ const fromOption = "From a question onward… Choose a starting question";
430
+ const exactOption = "Select exact text… Lines or characters";
431
+ const entireOption = `Entire side thread ${answered.length} Q&A · ~${estimateBringToMainTokens(entireSegments)} tokens`;
432
+ const cancelOption = "Cancel Return to the side thread";
433
+ let selectedScope: string | undefined;
434
+
435
+ while (true) {
436
+ const scopeResult = await showMenu(
437
+ ctx,
438
+ "Bring what back to the main thread?",
439
+ [latestOption, fromOption, exactOption, entireOption, cancelOption],
440
+ selectedScope,
441
+ );
442
+ if (scopeResult.kind === "close") return { kind: "closed" };
443
+ if (scopeResult.kind === "back" || scopeResult.value === cancelOption) return { kind: "back" };
444
+ const scope = scopeResult.value;
445
+ selectedScope = scope;
446
+ if (scope === latestOption) return makeChoice(latestSegments);
447
+ if (scope === entireOption) {
448
+ const choice = makeChoice(entireSegments);
449
+ const preview = await showPreview(ctx, choice.draft, choice.summary);
450
+ if (preview.kind === "close") return { kind: "closed" };
451
+ if (preview.kind === "back") continue;
452
+ return choice;
453
+ }
454
+ if (scope === fromOption) {
455
+ const questions = answered.map(
456
+ (turn, index) => `${index + 1}. ${truncatePreview(sanitizeSingleLine(turn.question))}`,
457
+ );
458
+ let selectedQuestion: string | undefined;
459
+ while (true) {
460
+ const questionResult = await showMenu(
461
+ ctx,
462
+ "Start from which question?",
463
+ questions,
464
+ selectedQuestion,
465
+ );
466
+ if (questionResult.kind === "close") return { kind: "closed" };
467
+ if (questionResult.kind === "back") break;
468
+ const answeredTurnIndex = questions.indexOf(questionResult.value);
469
+ if (answeredTurnIndex < 0) continue;
470
+ selectedQuestion = questionResult.value;
471
+ const choice = makeChoice(
472
+ buildQuickBringToMainSegments(thread.turns, { kind: "from", answeredTurnIndex }),
473
+ );
474
+ const preview = await showPreview(ctx, choice.draft, choice.summary);
475
+ if (preview.kind === "close") return { kind: "closed" };
476
+ if (preview.kind === "back") continue;
477
+ return choice;
478
+ }
479
+ continue;
480
+ }
481
+
482
+ if (scope !== exactOption) continue;
483
+ let selectionState: BtwTextRangeSelectorState | undefined;
484
+ while (true) {
485
+ const selectedRange = await showBtwCustomPreservingEditor<BtwBringToMainChoice>(
486
+ ctx,
487
+ (tui, theme, keybindings, done) => {
488
+ let selector: BtwTextRangeSelector;
489
+ selector = new BtwTextRangeSelector(
490
+ tui,
491
+ theme,
492
+ keybindings,
493
+ thread.turns,
494
+ (action) => {
495
+ if (action.kind === "back") done({ kind: "back" });
496
+ else if (action.kind === "close") done({ kind: "closed" });
497
+ else done({ ...makeChoice(action.segments), selectionState: selector.getState() });
498
+ },
499
+ selectionState,
500
+ );
501
+ return selector;
502
+ },
503
+ );
504
+ if (selectedRange.kind === "closed") return selectedRange;
505
+ if (selectedRange.kind === "back") break;
506
+ const preview = await showPreview(ctx, selectedRange.draft, selectedRange.summary);
507
+ if (preview.kind === "close") return { kind: "closed" };
508
+ if (preview.kind === "back") {
509
+ selectionState = selectedRange.selectionState;
510
+ continue;
511
+ }
512
+ return {
513
+ kind: "bringToMain",
514
+ draft: selectedRange.draft,
515
+ summary: selectedRange.summary,
516
+ };
517
+ }
518
+ }
519
+ }
520
+
521
+ async function showBringToMainPreview(
522
+ ctx: ExtensionCommandContext,
523
+ draft: string,
524
+ summary: BtwBringToMainSummary,
525
+ ): Promise<BtwBringToMainPreviewAction> {
526
+ return showBtwCustomPreservingEditor<BtwBringToMainPreviewAction>(
527
+ ctx,
528
+ (tui, theme, keybindings, done) =>
529
+ new BtwBringToMainPreview(tui, theme, keybindings, draft, summary, done),
530
+ );
531
+ }
532
+
533
+ async function showBtwMenu(
534
+ ctx: ExtensionCommandContext,
535
+ title: string,
536
+ options: readonly string[],
537
+ initialValue?: string,
538
+ ): Promise<BtwMenuSelectorAction> {
539
+ return showBtwCustomPreservingEditor<BtwMenuSelectorAction>(
540
+ ctx,
541
+ (tui, theme, keybindings, done) =>
542
+ new BtwMenuSelector(tui, theme, keybindings, title, options, done, initialValue),
543
+ );
544
+ }
545
+
546
+ export async function loadBringToMainDraft(
547
+ draft: string,
548
+ ctx: ExtensionCommandContext,
549
+ summary: BtwBringToMainSummary,
550
+ ): Promise<BtwBringToMainDelivery> {
551
+ const describeContent = () =>
552
+ `${summary.messages} ${summary.messages === 1 ? "message" : "messages"} (~${summary.tokens} ${summary.tokens === 1 ? "token" : "tokens"})`;
553
+ const existing = ctx.ui.getEditorText();
554
+ if (!existing.trim()) {
555
+ ctx.ui.setEditorText(draft);
556
+ ctx.ui.notify(
557
+ `Brought ${describeContent()} to the main editor. Review and submit when ready.`,
558
+ "info",
559
+ );
560
+ return "loaded";
561
+ }
562
+
563
+ const appendOption = "Append after current draft Recommended";
564
+ const replaceOption = "⚠ Replace current draft Discards current editor text";
565
+ const cancelOption = "Cancel Return to the side thread";
566
+ while (true) {
567
+ const action = await showBtwMenu(ctx, "The main editor already has a draft", [
568
+ appendOption,
569
+ replaceOption,
570
+ cancelOption,
571
+ ]);
572
+ if (action.kind === "close") return "closed";
573
+ if (action.kind === "back" || action.value === cancelOption) return "back";
574
+ if (action.value === appendOption) {
575
+ ctx.ui.setEditorText(`${ctx.ui.getEditorText()}\n\n${draft}`);
576
+ ctx.ui.notify(
577
+ `Appended ${describeContent()} to the existing main-editor draft. Review and submit when ready.`,
578
+ "info",
579
+ );
580
+ return "loaded";
581
+ }
582
+ if (action.value !== replaceOption) continue;
583
+
584
+ const current = ctx.ui.getEditorText();
585
+ const characters = [...current].length;
586
+ const confirmed = await showBtwMenu(
587
+ ctx,
588
+ `Replace the current ${characters}-character editor draft?`,
589
+ ["Back Keep current editor text", "⚠ Replace current draft Cannot be undone"],
590
+ );
591
+ if (confirmed.kind === "close") return "closed";
592
+ if (confirmed.kind === "back" || confirmed.value === "Back Keep current editor text") continue;
593
+ if (confirmed.value !== "⚠ Replace current draft Cannot be undone") continue;
594
+ if (ctx.ui.getEditorText() !== current) {
595
+ ctx.ui.notify(
596
+ "The main editor changed during confirmation. Review the updated draft and choose again.",
597
+ "warning",
598
+ );
599
+ continue;
600
+ }
601
+ ctx.ui.setEditorText(draft);
602
+ ctx.ui.notify(
603
+ `Replaced the main-editor draft with ${describeContent()}. Review and submit when ready.`,
604
+ "info",
605
+ );
606
+ return "loaded";
607
+ }
608
+ }
609
+
610
+ function truncatePreview(text: string): string {
611
+ return text.length <= 72 ? text : `${text.slice(0, 69)}…`;
612
+ }
613
+
334
614
  async function askThreadQuestion(
335
615
  thread: SideThread,
336
616
  question: string,
@@ -368,10 +648,14 @@ async function showThreadComposer(
368
648
  thread: SideThread,
369
649
  startAtBottom: boolean,
370
650
  ctx: ExtensionCommandContext,
651
+ initialQuestion?: string,
371
652
  ): Promise<TranscriptPagerAction> {
372
653
  return ctx.ui.custom<TranscriptPagerAction>(
373
654
  (tui, theme, _keybindings, done) =>
374
- new BtwTranscriptPager(tui, theme, thread.turns, done, { startAtBottom }),
655
+ new BtwTranscriptPager(tui, theme, thread.turns, done, {
656
+ startAtBottom,
657
+ initialQuestion,
658
+ }),
375
659
  );
376
660
  }
377
661
 
@@ -25,11 +25,15 @@ const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;
25
25
  // Pi renders a spacer above the custom component and a two-line built-in footer below it.
26
26
  const RESERVED_APP_LINES = 3;
27
27
 
28
- export type TranscriptPagerAction = { kind: "submit"; question: string } | { kind: "close" };
28
+ export type TranscriptPagerAction =
29
+ | { kind: "submit"; question: string }
30
+ | { kind: "bringToMain"; questionDraft: string }
31
+ | { kind: "close" };
29
32
 
30
33
  export class BtwTranscriptPager implements Component {
31
34
  private readonly transcriptComponents: Component[];
32
35
  private readonly editor: Editor;
36
+ private readonly canBringToMain: boolean;
33
37
  private scrollOffset = 0;
34
38
  private lastContentLineCount = 0;
35
39
  private lastViewportHeight = 1;
@@ -43,9 +47,10 @@ export class BtwTranscriptPager implements Component {
43
47
  private readonly theme: Theme,
44
48
  turns: readonly SideThreadTurn[],
45
49
  private readonly onAction: (action: TranscriptPagerAction) => void,
46
- options: { startAtBottom?: boolean } = {},
50
+ options: { startAtBottom?: boolean; initialQuestion?: string } = {},
47
51
  ) {
48
52
  this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
53
+ this.canBringToMain = turns.some((turn) => turn.kind === "answered");
49
54
  this.followBottom = options.startAtBottom ?? false;
50
55
  const editorTheme: EditorTheme = {
51
56
  borderColor: (text) => this.theme.fg("accent", text),
@@ -58,6 +63,7 @@ export class BtwTranscriptPager implements Component {
58
63
  },
59
64
  };
60
65
  this.editor = new Editor(this.tui, editorTheme);
66
+ if (options.initialQuestion) this.editor.setText(options.initialQuestion);
61
67
  this.editor.onChange = () => {
62
68
  this.warning = undefined;
63
69
  };
@@ -111,6 +117,11 @@ export class BtwTranscriptPager implements Component {
111
117
  this.onAction({ kind: "close" });
112
118
  return;
113
119
  }
120
+ if (this.canBringToMain && matchesKey(data, Key.ctrl("r"))) {
121
+ this.finished = true;
122
+ this.onAction({ kind: "bringToMain", questionDraft: this.editor.getExpandedText() });
123
+ return;
124
+ }
114
125
  if (matchesKey(data, Key.pageUp)) {
115
126
  const previousOffset = this.scrollOffset;
116
127
  this.scrollBy(-this.lastViewportHeight);
@@ -139,18 +150,33 @@ export class BtwTranscriptPager implements Component {
139
150
  return truncateToWidth(this.theme.fg("warning", warning), width);
140
151
  }
141
152
  const scrollable = this.getMaxScrollOffset() > 0;
142
- const fullBase = "btw • Enter send • Ctrl+C exit";
143
- const compactBase = "btw • Enter • Ctrl+C";
144
- let hints = visibleWidth(fullBase) <= width ? fullBase : compactBase;
153
+ const fullBase = this.canBringToMain
154
+ ? "btw • Enter send • Ctrl+R bring to main • Ctrl+C exit"
155
+ : "btw Enter send Ctrl+C exit";
156
+ const fallbackBase = "btw • Enter • Ctrl+C";
157
+ const compactBase = this.canBringToMain ? "btw • Enter • Ctrl+R • Ctrl+C" : fallbackBase;
158
+ let hints =
159
+ visibleWidth(fullBase) <= width
160
+ ? fullBase
161
+ : visibleWidth(compactBase) <= width
162
+ ? compactBase
163
+ : fallbackBase;
145
164
  if (scrollable) {
146
165
  const history = ` • ${this.scrollOffset > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
147
166
  const compactHistory = " • PgUp/PgDn";
167
+ const compactScrollable = this.canBringToMain
168
+ ? "Enter • Ctrl+R • Ctrl+C • PgUp/PgDn"
169
+ : `${fallbackBase}${compactHistory}`;
148
170
  if (visibleWidth(`${hints}${history}`) <= width) {
149
171
  hints += history;
172
+ } else if (visibleWidth(`${compactBase}${history}`) <= width) {
173
+ hints = `${compactBase}${history}`;
150
174
  } else if (visibleWidth(`${hints}${compactHistory}`) <= width) {
151
175
  hints += compactHistory;
152
176
  } else if (visibleWidth(`${compactBase}${compactHistory}`) <= width) {
153
177
  hints = `${compactBase}${compactHistory}`;
178
+ } else if (visibleWidth(compactScrollable) <= width) {
179
+ hints = compactScrollable;
154
180
  }
155
181
  }
156
182
  return truncateToWidth(this.theme.fg("muted", hints), width);