@narumitw/pi-btw 0.31.0 → 0.32.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 +32 -4
- package/package.json +1 -1
- package/src/bring-to-main.ts +870 -0
- package/src/btw.ts +289 -5
- package/src/transcript-pager.ts +31 -5
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.
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
│
|
|
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
|
@@ -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, "<btw_context")
|
|
855
|
+
.replace(/<\/btw_context[ \t\r\n]*>/g, (terminator) =>
|
|
856
|
+
terminator.replace("<", "<").replace(">", ">"),
|
|
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<
|
|
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, {
|
|
655
|
+
new BtwTranscriptPager(tui, theme, thread.turns, done, {
|
|
656
|
+
startAtBottom,
|
|
657
|
+
initialQuestion,
|
|
658
|
+
}),
|
|
375
659
|
);
|
|
376
660
|
}
|
|
377
661
|
|
package/src/transcript-pager.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
143
|
-
|
|
144
|
-
|
|
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);
|