@narumitw/pi-btw 0.54.0 → 0.54.2

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/dist/index.ts ADDED
@@ -0,0 +1,2879 @@
1
+ // @generated by scripts/build-runtime.mjs; do not edit.
2
+ // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
3
+
4
+ // src/btw.ts
5
+ import {
6
+ clampThinkingLevel,
7
+ getSupportedThinkingLevels
8
+ } from "@earendil-works/pi-ai";
9
+ import {
10
+ BorderedLoader
11
+ } from "@earendil-works/pi-coding-agent";
12
+
13
+ // src/bring-to-main.ts
14
+ import {
15
+ Key,
16
+ matchesKey,
17
+ truncateToWidth,
18
+ visibleWidth
19
+ } from "@earendil-works/pi-tui";
20
+ var RESERVED_APP_ROWS = 3;
21
+ var GRAPHEME_SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
22
+ function getAnsweredTurns(turns) {
23
+ return turns.filter(
24
+ (turn) => turn.kind === "answered"
25
+ );
26
+ }
27
+ function buildQuickBringToMainSegments(turns, scope) {
28
+ const answered = getAnsweredTurns(turns);
29
+ const selected = scope.kind === "latest" ? answered.slice(-1) : scope.kind === "from" ? answered.slice(Math.max(0, scope.answeredTurnIndex)) : answered;
30
+ return selected.flatMap((turn) => [
31
+ { role: "user", text: turn.question },
32
+ { role: "assistant", text: turn.answer }
33
+ ]);
34
+ }
35
+ function buildBtwSelectionLines(turns) {
36
+ return buildQuickBringToMainSegments(turns, { kind: "entire" }).flatMap(
37
+ (segment) => segment.text.split("\n").map((text) => ({ role: segment.role, text }))
38
+ );
39
+ }
40
+ function segmentsFromLineRange(lines, anchor, cursor) {
41
+ if (lines.length === 0) return [];
42
+ const start = Math.max(0, Math.min(anchor, cursor, lines.length - 1));
43
+ const end = Math.max(0, Math.min(Math.max(anchor, cursor), lines.length - 1));
44
+ const segments = [];
45
+ for (const line of lines.slice(start, end + 1)) {
46
+ const previous = segments.at(-1);
47
+ if (previous?.role === line.role) {
48
+ previous.text += `
49
+ ${line.text}`;
50
+ } else {
51
+ segments.push({ role: line.role, text: line.text });
52
+ }
53
+ }
54
+ return segments;
55
+ }
56
+ function segmentsFromTextRange(lines, anchor, cursor) {
57
+ if (lines.length === 0) return [];
58
+ const first = clampTextPosition(lines, anchor);
59
+ const second = clampTextPosition(lines, cursor);
60
+ const [start, end] = compareTextPositions(first, second) <= 0 ? [first, second] : [second, first];
61
+ if (compareTextPositions(start, end) === 0) return [];
62
+ const segments = [];
63
+ for (let lineIndex = start.line; lineIndex <= end.line; lineIndex += 1) {
64
+ const line = lines[lineIndex];
65
+ if (!line) continue;
66
+ const characters = splitGraphemes(line.text);
67
+ const from = lineIndex === start.line ? start.column : 0;
68
+ const to = lineIndex === end.line ? end.column : characters.length;
69
+ const text = characters.slice(from, to).join("");
70
+ if (text) {
71
+ const previous = segments.at(-1);
72
+ if (previous?.role === line.role) previous.text += text;
73
+ else segments.push({ role: line.role, text });
74
+ }
75
+ const crossesSameRoleLine = lineIndex < end.line && lines[lineIndex + 1]?.role === line.role;
76
+ if (crossesSameRoleLine) {
77
+ const current = segments.at(-1);
78
+ if (current?.role === line.role) current.text += "\n";
79
+ else segments.push({ role: line.role, text: "\n" });
80
+ }
81
+ }
82
+ return segments;
83
+ }
84
+ function estimateBringToMainTokens(segments) {
85
+ return Math.ceil(
86
+ Buffer.byteLength(segments.map((segment) => segment.text).join("\n"), "utf8") / 4
87
+ );
88
+ }
89
+ function summarizeBringToMain(segments) {
90
+ return {
91
+ lines: segments.reduce((count, segment) => count + segment.text.split("\n").length, 0),
92
+ messages: segments.length,
93
+ tokens: estimateBringToMainTokens(segments)
94
+ };
95
+ }
96
+ function formatBtwBringToMain(segments) {
97
+ const body = segments.map(
98
+ (segment) => `${segment.role === "user" ? "User" : "Assistant"}:
99
+ ${escapeBringToMainText(segment.text)}`
100
+ ).join("\n\n");
101
+ return [
102
+ "The following context was brought back from a /btw side discussion.",
103
+ "Treat it as discussion context, not as work already completed.",
104
+ "",
105
+ "<btw_context>",
106
+ body,
107
+ "</btw_context>"
108
+ ].join("\n");
109
+ }
110
+ var BtwTextRangeSelector = class {
111
+ constructor(tui, theme, keybindings, turns, onAction, initialState) {
112
+ this.tui = tui;
113
+ this.theme = theme;
114
+ this.keybindings = keybindings;
115
+ this.onAction = onAction;
116
+ this.lines = buildBtwSelectionLines(turns);
117
+ if (initialState) {
118
+ this.cursor = clampTextPosition(this.lines, initialState.cursor);
119
+ this.anchor = initialState.anchor ? clampTextPosition(this.lines, initialState.anchor) : void 0;
120
+ this.lineAnchor = initialState.lineAnchor === void 0 ? void 0 : Math.max(0, Math.min(this.lines.length - 1, initialState.lineAnchor));
121
+ this.preferredColumn = Math.max(0, initialState.preferredColumn);
122
+ this.scrollOffset = Math.max(0, initialState.scrollOffset);
123
+ this.horizontalOffset = Math.max(0, initialState.horizontalOffset);
124
+ }
125
+ }
126
+ tui;
127
+ theme;
128
+ keybindings;
129
+ onAction;
130
+ lines;
131
+ cursor = { line: 0, column: 0 };
132
+ anchor;
133
+ lineAnchor;
134
+ preferredColumn = 0;
135
+ scrollOffset = 0;
136
+ horizontalOffset = 0;
137
+ warning;
138
+ finished = false;
139
+ getState() {
140
+ return {
141
+ cursor: { ...this.cursor },
142
+ anchor: this.anchor ? { ...this.anchor } : void 0,
143
+ lineAnchor: this.lineAnchor,
144
+ preferredColumn: this.preferredColumn,
145
+ scrollOffset: this.scrollOffset,
146
+ horizontalOffset: this.horizontalOffset
147
+ };
148
+ }
149
+ render(width) {
150
+ const safeWidth = Math.max(1, width);
151
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
152
+ const showStatus = availableRows >= 4;
153
+ const showFooter = availableRows >= 3;
154
+ const viewportHeight = Math.max(
155
+ 1,
156
+ availableRows - 1 - (showStatus ? 1 : 0) - (showFooter ? 1 : 0)
157
+ );
158
+ this.keepCursorVisible(viewportHeight);
159
+ const textWidth = Math.max(1, safeWidth - visibleWidth("\u25CF> Assistant \u2502 "));
160
+ this.keepCursorHorizontallyVisible(textWidth);
161
+ const range = this.getSelectionRange();
162
+ const lineRange = this.getLineSelectionRange();
163
+ const visible = this.lines.slice(this.scrollOffset, this.scrollOffset + viewportHeight);
164
+ const rows = visible.map((line, visibleIndex) => {
165
+ const lineIndex = this.scrollOffset + visibleIndex;
166
+ const role = line.role === "user" ? "User" : "Assistant";
167
+ const lineSelected = lineRange ? lineIndex >= lineRange.start && lineIndex <= lineRange.end : false;
168
+ const prefix = `${lineSelected ? "\u25CF" : " "}${lineIndex === this.cursor.line ? ">" : " "} ${role.padEnd(9)} \u2502 `;
169
+ const text = this.renderTextLine(line, lineIndex, range, lineSelected);
170
+ return truncateToWidth(
171
+ lineIndex === this.cursor.line ? this.theme.fg("accent", prefix) + text : prefix + text,
172
+ safeWidth,
173
+ ""
174
+ );
175
+ });
176
+ const selected = this.getSelectedSegments();
177
+ const summary = summarizeBringToMain(selected);
178
+ const status = selected.length === 0 ? "Selected: none" : `Selected: ${summary.lines} ${summary.lines === 1 ? "line" : "lines"} \xB7 ${summary.messages} ${summary.messages === 1 ? "message" : "messages"} \xB7 ~${summary.tokens} ${summary.tokens === 1 ? "token" : "tokens"}`;
179
+ const confirm = confirmKeyLabel(this.keybindings);
180
+ const back = keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"]);
181
+ const vertical = `${keybindingLabel(this.keybindings, "tui.select.up")}/${keybindingLabel(this.keybindings, "tui.select.down")}`;
182
+ const confirmUsesSpace = this.keybindings.matches(" ", "tui.select.confirm");
183
+ const detailedFooter = this.warning ? `${this.warning} \u2022 ${confirmUsesSpace ? "Shift+Arrows select" : "Space lines \u2022 Shift+Arrows text"} \u2022 ${back} back \u2022 Ctrl+C close` : this.lineAnchor !== void 0 ? `${confirmUsesSpace ? `${vertical} extend lines` : `Space clear \u2022 ${vertical} extend lines`} \u2022 Shift+Arrows text \u2022 ${confirm} bring \u2022 ${back} back \u2022 Ctrl+C close` : `Shift+Arrows select \u2022 Arrows move${confirmUsesSpace ? "" : " \u2022 Space lines"} \u2022 ${confirm} bring \u2022 ${back} back \u2022 Ctrl+C close`;
184
+ const criticalFooter = `${confirm} bring \u2022 ${back} back \u2022 Ctrl+C close`;
185
+ const footer = visibleWidth(detailedFooter) <= safeWidth ? detailedFooter : criticalFooter;
186
+ return fitRows(
187
+ [
188
+ truncateToWidth(
189
+ this.theme.fg("accent", this.theme.bold("Select text to bring to main")),
190
+ safeWidth,
191
+ ""
192
+ ),
193
+ ...showStatus ? [truncateToWidth(this.theme.fg("muted", status), safeWidth, "")] : [],
194
+ ...rows,
195
+ ...showFooter ? [
196
+ truncateToWidth(
197
+ this.theme.fg(this.warning ? "warning" : "muted", footer),
198
+ safeWidth,
199
+ ""
200
+ )
201
+ ] : []
202
+ ],
203
+ availableRows
204
+ );
205
+ }
206
+ handleInput(data) {
207
+ if (this.finished) return;
208
+ if (matchesKey(data, Key.ctrl("c"))) {
209
+ this.finish({ kind: "close" });
210
+ return;
211
+ }
212
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
213
+ this.finish({ kind: "back" });
214
+ return;
215
+ }
216
+ if (matchesConfirm(data, this.keybindings)) {
217
+ if (this.lines.length > 0) {
218
+ const segments = this.getSelectedSegments();
219
+ if (segments.length === 0) {
220
+ this.warning = "Select text first";
221
+ this.tui.requestRender();
222
+ } else {
223
+ this.finish({ kind: "confirm", segments });
224
+ }
225
+ }
226
+ return;
227
+ }
228
+ if (matchesKey(data, Key.space)) {
229
+ this.anchor = void 0;
230
+ this.lineAnchor = this.lineAnchor === void 0 ? this.cursor.line : void 0;
231
+ this.afterMove();
232
+ return;
233
+ }
234
+ if (matchesKey(data, Key.shift("left"))) {
235
+ this.moveHorizontal(-1, true);
236
+ return;
237
+ }
238
+ if (matchesKey(data, Key.shift("right"))) {
239
+ this.moveHorizontal(1, true);
240
+ return;
241
+ }
242
+ if (matchesKey(data, Key.shift("up"))) {
243
+ this.moveVertical(-1, true);
244
+ return;
245
+ }
246
+ if (matchesKey(data, Key.shift("down"))) {
247
+ this.moveVertical(1, true);
248
+ return;
249
+ }
250
+ if (matchesKey(data, Key.left)) {
251
+ this.moveHorizontal(-1, false);
252
+ return;
253
+ }
254
+ if (matchesKey(data, Key.right)) {
255
+ this.moveHorizontal(1, false);
256
+ return;
257
+ }
258
+ if (this.keybindings.matches(data, "tui.select.up")) {
259
+ this.moveVertical(-1, false);
260
+ return;
261
+ }
262
+ if (this.keybindings.matches(data, "tui.select.down")) {
263
+ this.moveVertical(1, false);
264
+ return;
265
+ }
266
+ if (this.keybindings.matches(data, "tui.select.pageUp")) {
267
+ this.moveVertical(-10, false);
268
+ return;
269
+ }
270
+ if (this.keybindings.matches(data, "tui.select.pageDown")) {
271
+ this.moveVertical(10, false);
272
+ return;
273
+ }
274
+ }
275
+ invalidate() {
276
+ }
277
+ renderTextLine(line, lineIndex, range, lineSelected) {
278
+ const characters = splitGraphemes(line.text);
279
+ let rendered = this.horizontalOffset > 0 ? this.theme.fg("muted", "\u2026") : "";
280
+ let buffer = "";
281
+ let bufferSelected = false;
282
+ const flush = () => {
283
+ if (!buffer) return;
284
+ rendered += bufferSelected ? this.theme.bg("selectedBg", this.theme.fg("text", buffer)) : buffer;
285
+ buffer = "";
286
+ };
287
+ for (let column = this.horizontalOffset; column <= characters.length; column += 1) {
288
+ if (range && lineIndex === range.start.line && column === range.start.column) {
289
+ flush();
290
+ rendered += this.theme.fg("accent", "[");
291
+ }
292
+ if (lineIndex === this.cursor.line && column === this.cursor.column) {
293
+ flush();
294
+ rendered += this.theme.fg("accent", "\u2502");
295
+ }
296
+ if (range && lineIndex === range.end.line && column === range.end.column) {
297
+ flush();
298
+ rendered += this.theme.fg("accent", "]");
299
+ }
300
+ const character = characters[column];
301
+ if (character === void 0) continue;
302
+ const selected = lineSelected || (range ? positionFallsInside(lineIndex, column, range) : false);
303
+ if (buffer && selected !== bufferSelected) flush();
304
+ bufferSelected = selected;
305
+ buffer += escapeTerminalControls(character);
306
+ }
307
+ flush();
308
+ return rendered;
309
+ }
310
+ getSelectionRange() {
311
+ if (!this.anchor || compareTextPositions(this.anchor, this.cursor) === 0) return void 0;
312
+ return compareTextPositions(this.anchor, this.cursor) < 0 ? { start: this.anchor, end: this.cursor } : { start: this.cursor, end: this.anchor };
313
+ }
314
+ getLineSelectionRange() {
315
+ return this.lineAnchor === void 0 ? void 0 : {
316
+ start: Math.min(this.lineAnchor, this.cursor.line),
317
+ end: Math.max(this.lineAnchor, this.cursor.line)
318
+ };
319
+ }
320
+ getSelectedSegments() {
321
+ if (this.lineAnchor !== void 0) {
322
+ return segmentsFromLineRange(this.lines, this.lineAnchor, this.cursor.line);
323
+ }
324
+ return this.anchor ? segmentsFromTextRange(this.lines, this.anchor, this.cursor) : [];
325
+ }
326
+ moveHorizontal(delta, extend) {
327
+ if (this.lines.length === 0) return;
328
+ if (!extend) this.lineAnchor = void 0;
329
+ if (!extend && this.anchor) {
330
+ const range = this.getSelectionRange();
331
+ if (range) this.cursor = delta < 0 ? range.start : range.end;
332
+ this.anchor = void 0;
333
+ this.preferredColumn = this.cursor.column;
334
+ this.afterMove();
335
+ return;
336
+ }
337
+ this.beginOrClearSelection(extend);
338
+ const line = this.lines[this.cursor.line];
339
+ const length = line ? splitGraphemes(line.text).length : 0;
340
+ if (delta < 0) {
341
+ if (this.cursor.column > 0) this.cursor = { ...this.cursor, column: this.cursor.column - 1 };
342
+ else if (this.cursor.line > 0) {
343
+ const previousLine = this.lines[this.cursor.line - 1];
344
+ this.cursor = {
345
+ line: this.cursor.line - 1,
346
+ column: previousLine ? splitGraphemes(previousLine.text).length : 0
347
+ };
348
+ }
349
+ } else if (this.cursor.column < length) {
350
+ this.cursor = { ...this.cursor, column: this.cursor.column + 1 };
351
+ } else if (this.cursor.line < this.lines.length - 1) {
352
+ this.cursor = { line: this.cursor.line + 1, column: 0 };
353
+ }
354
+ this.preferredColumn = this.cursor.column;
355
+ this.afterMove();
356
+ }
357
+ moveVertical(delta, extend) {
358
+ if (this.lines.length === 0) return;
359
+ if (extend || this.lineAnchor === void 0) this.beginOrClearSelection(extend);
360
+ const line = Math.max(0, Math.min(this.lines.length - 1, this.cursor.line + delta));
361
+ const target = this.lines[line];
362
+ this.cursor = {
363
+ line,
364
+ column: Math.min(this.preferredColumn, target ? splitGraphemes(target.text).length : 0)
365
+ };
366
+ this.afterMove();
367
+ }
368
+ beginOrClearSelection(extend) {
369
+ if (extend) this.lineAnchor = void 0;
370
+ if (extend && !this.anchor) this.anchor = { ...this.cursor };
371
+ if (!extend) this.anchor = void 0;
372
+ }
373
+ afterMove() {
374
+ this.warning = void 0;
375
+ this.tui.requestRender();
376
+ }
377
+ keepCursorVisible(height) {
378
+ if (height <= 0) return;
379
+ if (this.cursor.line < this.scrollOffset) this.scrollOffset = this.cursor.line;
380
+ if (this.cursor.line >= this.scrollOffset + height) {
381
+ this.scrollOffset = this.cursor.line - height + 1;
382
+ }
383
+ }
384
+ keepCursorHorizontallyVisible(width) {
385
+ const characters = splitGraphemes(this.lines[this.cursor.line]?.text ?? "");
386
+ const displayWidths = characters.map(
387
+ (character) => visibleWidth(escapeTerminalControls(character))
388
+ );
389
+ const currentWidth = displayWidths[this.cursor.column] ?? 0;
390
+ let usedWidth = 1 + Math.min(currentWidth, Math.max(0, width - 1));
391
+ let offset = this.cursor.column;
392
+ for (let index = this.cursor.column - 1; index >= 0; index -= 1) {
393
+ const nextWidth = usedWidth + (displayWidths[index] ?? 0) + (index > 0 ? 1 : 0);
394
+ if (nextWidth > width) break;
395
+ usedWidth += displayWidths[index] ?? 0;
396
+ offset = index;
397
+ }
398
+ this.horizontalOffset = offset;
399
+ }
400
+ finish(action) {
401
+ if (this.finished) return;
402
+ this.finished = true;
403
+ this.onAction(action);
404
+ }
405
+ };
406
+ function clampTextPosition(lines, position) {
407
+ const line = Math.max(0, Math.min(lines.length - 1, position.line));
408
+ const text = lines[line]?.text ?? "";
409
+ return {
410
+ line,
411
+ column: Math.max(0, Math.min(splitGraphemes(text).length, position.column))
412
+ };
413
+ }
414
+ function confirmKeyLabel(keybindings) {
415
+ return keybindingLabel(keybindings, "tui.select.confirm", ["ctrl+c"], "enter");
416
+ }
417
+ function matchesConfirm(data, keybindings) {
418
+ if (matchesKey(data, Key.ctrl("c"))) return false;
419
+ const hasUsableBinding = keybindings.getKeys("tui.select.confirm").map(String).some((key) => key.toLowerCase() !== "ctrl+c");
420
+ return keybindings.matches(data, "tui.select.confirm") || !hasUsableBinding && matchesKey(data, Key.enter);
421
+ }
422
+ function keybindingLabel(keybindings, keybinding, excluded = [], fallback) {
423
+ const key = keybindings.getKeys(keybinding).map(String).find((candidate) => !excluded.includes(candidate.toLowerCase()));
424
+ return formatKeyLabel(key ?? fallback ?? keybinding);
425
+ }
426
+ function formatKeyLabel(key) {
427
+ return key.split("+").map((part) => {
428
+ const lower = part.toLowerCase();
429
+ if (lower === "ctrl") return "Ctrl";
430
+ if (lower === "alt") return "Alt";
431
+ if (lower === "shift") return "Shift";
432
+ if (lower === "escape" || lower === "esc") return "Esc";
433
+ if (lower === "enter" || lower === "return") return "Enter";
434
+ if (lower === "pageup") return "PgUp";
435
+ if (lower === "pagedown") return "PgDn";
436
+ return part.length === 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
437
+ }).join("+");
438
+ }
439
+ function splitGraphemes(text) {
440
+ return [...GRAPHEME_SEGMENTER.segment(text)].map(({ segment }) => segment);
441
+ }
442
+ function compareTextPositions(first, second) {
443
+ return first.line === second.line ? first.column - second.column : first.line - second.line;
444
+ }
445
+ function positionFallsInside(line, column, range) {
446
+ const position = { line, column };
447
+ return compareTextPositions(position, range.start) >= 0 && compareTextPositions(position, range.end) < 0;
448
+ }
449
+ function fitRows(rows, availableRows) {
450
+ if (rows.length <= availableRows) return rows;
451
+ if (availableRows <= 1) return rows.slice(0, 1);
452
+ return [rows[0] ?? "", ...rows.slice(rows.length - availableRows + 1)];
453
+ }
454
+ function escapeBringToMainText(text) {
455
+ return [...text].map((character) => {
456
+ if (character === "\n") return character;
457
+ if (character === " ") return " ";
458
+ const code = character.charCodeAt(0);
459
+ if (code <= 31 || code >= 127 && code <= 159) {
460
+ return `\\x${code.toString(16).padStart(2, "0")}`;
461
+ }
462
+ return character;
463
+ }).join("").replace(/<btw_context(?=[ \t\r\n>])/g, "&lt;btw_context").replace(
464
+ /<\/btw_context[ \t\r\n]*>/g,
465
+ (terminator) => terminator.replaceAll("<", "&lt;").replaceAll(">", "&gt;")
466
+ );
467
+ }
468
+ function escapeTerminalControls(text) {
469
+ return [...text].map((character) => {
470
+ const code = character.charCodeAt(0);
471
+ if (code <= 31 || code >= 127 && code <= 159) {
472
+ return `\\x${code.toString(16).padStart(2, "0")}`;
473
+ }
474
+ return character;
475
+ }).join("");
476
+ }
477
+
478
+ // src/fullscreen-ui.ts
479
+ import { spawn } from "node:child_process";
480
+ import {
481
+ TuiAltScreen,
482
+ truncateToWidth as truncateToWidth2
483
+ } from "@earendil-works/pi-tui";
484
+
485
+ // src/text.ts
486
+ function sanitizeSingleLine(text) {
487
+ return [...text.replace(/[\r\n\t]/gu, " ")].filter((character) => {
488
+ const code = character.charCodeAt(0);
489
+ return code > 31 && (code < 127 || code > 159);
490
+ }).join("").replace(/ +/gu, " ").trim();
491
+ }
492
+
493
+ // src/fullscreen-ui.ts
494
+ var FullscreenUiDisposedError = class extends Error {
495
+ constructor() {
496
+ super("The dedicated pi-btw UI was disposed.");
497
+ this.name = "FullscreenUiDisposedError";
498
+ }
499
+ };
500
+ async function runBtwFullscreen(ctx, run, dependencies = {}) {
501
+ const createTui = dependencies.createTui ?? ((parent) => createBtwFullscreenTui(parent, dependencies.openUrl ?? openUrlInBrowser));
502
+ let liveEditorText = ctx.ui.getEditorText();
503
+ let restoreEditor = false;
504
+ const outcome = await ctx.ui.custom(
505
+ (parent, theme, keybindings, done) => new BtwFullscreenHost(
506
+ parent,
507
+ theme,
508
+ keybindings,
509
+ ctx,
510
+ run,
511
+ (value) => {
512
+ try {
513
+ liveEditorText = ctx.ui.getEditorText();
514
+ restoreEditor = true;
515
+ } catch {
516
+ }
517
+ done(value);
518
+ },
519
+ createTui
520
+ )
521
+ );
522
+ if (restoreEditor) {
523
+ try {
524
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
525
+ } catch {
526
+ }
527
+ }
528
+ if (outcome.kind === "failed") throw outcome.error;
529
+ return outcome.value;
530
+ }
531
+ function createBtwFullscreenTui(parent, openUrl) {
532
+ return new TuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), void 0, {
533
+ mouse: true,
534
+ openUrl
535
+ });
536
+ }
537
+ function openUrlInBrowser(target) {
538
+ const [command, args] = process.platform === "darwin" ? ["open", [target]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", target]] : ["xdg-open", [target]];
539
+ spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => {
540
+ }).unref();
541
+ }
542
+ var BtwFullscreenHost = class {
543
+ constructor(parent, theme, keybindings, ctx, run, done, createTui) {
544
+ this.parent = parent;
545
+ this.theme = theme;
546
+ this.keybindings = keybindings;
547
+ this.ctx = ctx;
548
+ this.run = run;
549
+ this.done = done;
550
+ this.createTui = createTui;
551
+ queueMicrotask(() => void this.start());
552
+ }
553
+ parent;
554
+ theme;
555
+ keybindings;
556
+ ctx;
557
+ run;
558
+ done;
559
+ createTui;
560
+ fullscreen;
561
+ cancelActiveCustom;
562
+ started = false;
563
+ disposed = false;
564
+ finished = false;
565
+ render(width) {
566
+ return [truncateToWidth2(this.theme.fg("muted", "Opening btw side thread\u2026"), width)];
567
+ }
568
+ invalidate() {
569
+ }
570
+ dispose() {
571
+ if (this.disposed || this.finished) return;
572
+ this.disposed = true;
573
+ this.cancelActiveCustom?.();
574
+ }
575
+ async start() {
576
+ if (this.started || this.finished) return;
577
+ this.started = true;
578
+ let outcome;
579
+ let parentStopped = false;
580
+ let fullscreenCreated = false;
581
+ try {
582
+ if (this.disposed) throw new FullscreenUiDisposedError();
583
+ this.parent.stop({ preserveScreen: true });
584
+ parentStopped = true;
585
+ if (this.disposed) throw new FullscreenUiDisposedError();
586
+ this.fullscreen = this.createTui(this.parent);
587
+ fullscreenCreated = true;
588
+ this.fullscreen.start();
589
+ outcome = { kind: "completed", value: await this.run(this.createContext()) };
590
+ } catch (error) {
591
+ outcome = { kind: "failed", error };
592
+ }
593
+ let cleanupError;
594
+ try {
595
+ this.cancelActiveCustom?.();
596
+ } catch (error) {
597
+ cleanupError = error;
598
+ }
599
+ if (fullscreenCreated) {
600
+ try {
601
+ this.fullscreen?.stop({ preserveScreen: true });
602
+ } catch (error) {
603
+ cleanupError ??= error;
604
+ }
605
+ }
606
+ if (parentStopped) {
607
+ try {
608
+ this.parent.start();
609
+ this.parent.renderNow(false);
610
+ } catch (error) {
611
+ cleanupError ??= error;
612
+ }
613
+ }
614
+ if (cleanupError !== void 0) outcome = { kind: "failed", error: cleanupError };
615
+ this.finished = true;
616
+ this.done(outcome);
617
+ }
618
+ createContext() {
619
+ const ui = new Proxy(this.ctx.ui, {
620
+ get: (target, property) => {
621
+ if (property === "custom") {
622
+ return (factory, options) => this.showCustom(factory, options);
623
+ }
624
+ if (property === "notify") {
625
+ return (message, level) => {
626
+ target.notify(message, level);
627
+ const display = sanitizeSingleLine(message);
628
+ if (display) this.fullscreen?.flash?.(display);
629
+ };
630
+ }
631
+ const value = Reflect.get(target, property, target);
632
+ return typeof value === "function" ? value.bind(target) : value;
633
+ }
634
+ });
635
+ return new Proxy(this.ctx, {
636
+ get: (target, property) => property === "ui" ? ui : Reflect.get(target, property, target)
637
+ });
638
+ }
639
+ showCustom(factory, options) {
640
+ const fullscreen = this.fullscreen;
641
+ if (!fullscreen || this.disposed || this.finished) {
642
+ return Promise.reject(new FullscreenUiDisposedError());
643
+ }
644
+ if (this.cancelActiveCustom) {
645
+ return Promise.reject(new Error("pi-btw attempted to open overlapping custom UI."));
646
+ }
647
+ return new Promise((resolve, reject) => {
648
+ let component;
649
+ let overlay;
650
+ let mounted = false;
651
+ let layoutMounted = false;
652
+ let factorySettled = false;
653
+ let closed = false;
654
+ let promiseSettled = false;
655
+ let componentDisposed = false;
656
+ let pendingValue;
657
+ let hasPendingValue = false;
658
+ const disposeComponent = () => {
659
+ if (!component || componentDisposed) return;
660
+ componentDisposed = true;
661
+ try {
662
+ component.dispose?.();
663
+ } catch {
664
+ }
665
+ };
666
+ const unmount = () => {
667
+ let cleanupError;
668
+ try {
669
+ if (overlay) overlay.hide();
670
+ else if (mounted && layoutMounted) fullscreen.setLayoutRoot(void 0);
671
+ else if (mounted && component) fullscreen.removeChild(component);
672
+ } catch (error) {
673
+ cleanupError = error;
674
+ }
675
+ if (overlay || mounted) {
676
+ try {
677
+ fullscreen.setFocus(null);
678
+ fullscreen.requestRender();
679
+ } catch (error) {
680
+ cleanupError ??= error;
681
+ }
682
+ }
683
+ disposeComponent();
684
+ if (cleanupError !== void 0) throw cleanupError;
685
+ };
686
+ const complete = () => {
687
+ if (promiseSettled || !hasPendingValue) return;
688
+ promiseSettled = true;
689
+ this.cancelActiveCustom = void 0;
690
+ if (!factorySettled) {
691
+ resolve(pendingValue);
692
+ return;
693
+ }
694
+ try {
695
+ unmount();
696
+ resolve(pendingValue);
697
+ } catch (error) {
698
+ reject(error);
699
+ }
700
+ };
701
+ const close = (value) => {
702
+ if (closed || promiseSettled) return;
703
+ closed = true;
704
+ pendingValue = value;
705
+ hasPendingValue = true;
706
+ complete();
707
+ };
708
+ const fail = (error) => {
709
+ if (promiseSettled) return;
710
+ closed = true;
711
+ promiseSettled = true;
712
+ this.cancelActiveCustom = void 0;
713
+ try {
714
+ unmount();
715
+ reject(error);
716
+ } catch (cleanupError) {
717
+ reject(cleanupError);
718
+ }
719
+ };
720
+ this.cancelActiveCustom = () => {
721
+ if (promiseSettled) return;
722
+ disposeComponent();
723
+ if (!promiseSettled) fail(new FullscreenUiDisposedError());
724
+ };
725
+ let created;
726
+ try {
727
+ created = factory(fullscreen, this.theme, this.keybindings, close);
728
+ } catch (error) {
729
+ factorySettled = true;
730
+ fail(error);
731
+ return;
732
+ }
733
+ Promise.resolve(created).then((value) => {
734
+ component = value;
735
+ factorySettled = true;
736
+ if (promiseSettled) {
737
+ disposeComponent();
738
+ return;
739
+ }
740
+ if (closed) {
741
+ complete();
742
+ return;
743
+ }
744
+ if (options?.overlay) {
745
+ const overlayOptions = typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions;
746
+ overlay = fullscreen.showOverlay(component, overlayOptions);
747
+ options.onHandle?.(overlay);
748
+ } else {
749
+ fullscreen.clear();
750
+ mounted = true;
751
+ if (isFullscreenLayoutComponent(component)) {
752
+ layoutMounted = true;
753
+ fullscreen.setLayoutRoot(component.getFullscreenLayout());
754
+ } else {
755
+ fullscreen.addChild(component);
756
+ }
757
+ fullscreen.setFocus(component);
758
+ fullscreen.requestRender();
759
+ }
760
+ }).catch(fail);
761
+ });
762
+ }
763
+ };
764
+ function isFullscreenLayoutComponent(component) {
765
+ return "getFullscreenLayout" in component && typeof component.getFullscreenLayout === "function";
766
+ }
767
+
768
+ // src/main-tree-picker.ts
769
+ import {
770
+ copyToClipboard,
771
+ TreeSelectorComponent
772
+ } from "@earendil-works/pi-coding-agent";
773
+ import { Key as Key2, matchesKey as matchesKey2 } from "@earendil-works/pi-tui";
774
+
775
+ // src/settings.ts
776
+ import { randomUUID } from "node:crypto";
777
+ import { constants } from "node:fs";
778
+ import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
779
+ import { basename, dirname, join } from "node:path";
780
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
781
+
782
+ // src/side-thread.ts
783
+ var BTW_THINKING_LEVELS = [
784
+ "off",
785
+ "minimal",
786
+ "low",
787
+ "medium",
788
+ "high",
789
+ "xhigh",
790
+ "max"
791
+ ];
792
+ function createSideThread(conversationContext) {
793
+ return { conversationContext, turns: [] };
794
+ }
795
+ function buildSideThreadMessages(thread, question) {
796
+ const answeredTurns = thread.turns.filter(
797
+ (turn) => turn.kind === "answered"
798
+ );
799
+ const messages = [];
800
+ if (answeredTurns.length === 0) {
801
+ messages.push(createUserMessage(buildUserPrompt(question, thread.conversationContext)));
802
+ return messages;
803
+ }
804
+ const [first, ...rest] = answeredTurns;
805
+ messages.push(
806
+ createUserMessage(buildUserPrompt(first.question, thread.conversationContext)),
807
+ first.response
808
+ );
809
+ for (const turn of rest) {
810
+ messages.push(createUserMessage(buildFollowUpPrompt(turn.question)), turn.response);
811
+ }
812
+ messages.push(createUserMessage(buildFollowUpPrompt(question)));
813
+ return messages;
814
+ }
815
+ async function completeSideThreadTurn({
816
+ thread,
817
+ model,
818
+ question,
819
+ thinkingLevel,
820
+ auth,
821
+ signal,
822
+ completeSimple
823
+ }) {
824
+ if (signal?.aborted) return { kind: "aborted" };
825
+ try {
826
+ const response = await completeSimple(
827
+ model,
828
+ { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
829
+ buildStreamOptions(auth, thinkingLevel, signal)
830
+ );
831
+ if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
832
+ if (!isAssistantMessage(response)) {
833
+ return { kind: "error", message: "The side model returned a malformed response." };
834
+ }
835
+ if (response.stopReason === "error") {
836
+ return {
837
+ kind: "error",
838
+ message: response.errorMessage ?? "The side model returned an error."
839
+ };
840
+ }
841
+ const answer = extractAssistantText(response) || "No response received.";
842
+ thread.turns.push({ kind: "answered", question, answer, response });
843
+ return { kind: "answered", response, answer };
844
+ } catch (error) {
845
+ if (signal?.aborted) return { kind: "aborted" };
846
+ return { kind: "error", message: formatError(error) };
847
+ }
848
+ }
849
+ function extractAssistantText(response) {
850
+ return response.content.filter(
851
+ (content) => content !== null && typeof content === "object" && content.type === "text" && typeof content.text === "string"
852
+ ).map((content) => content.text).join("\n").trim();
853
+ }
854
+ function isAssistantMessage(value) {
855
+ if (value === null || typeof value !== "object") return false;
856
+ const candidate = value;
857
+ return candidate.role === "assistant" && Array.isArray(candidate.content) && typeof candidate.stopReason === "string";
858
+ }
859
+ function buildUserPrompt(question, conversationContext) {
860
+ return [
861
+ "Answer this side question without modifying the main conversation.",
862
+ "",
863
+ "<side_question>",
864
+ question,
865
+ "</side_question>",
866
+ "",
867
+ "<conversation_context>",
868
+ conversationContext || "No prior conversation context was available.",
869
+ "</conversation_context>"
870
+ ].join("\n");
871
+ }
872
+ function buildFollowUpPrompt(question) {
873
+ return [
874
+ "Continue the same side conversation.",
875
+ "",
876
+ "<side_question>",
877
+ question,
878
+ "</side_question>"
879
+ ].join("\n");
880
+ }
881
+ function createUserMessage(text) {
882
+ return {
883
+ role: "user",
884
+ content: [{ type: "text", text }],
885
+ timestamp: Date.now()
886
+ };
887
+ }
888
+ function buildStreamOptions(auth, thinkingLevel, signal) {
889
+ const options = {
890
+ apiKey: auth.apiKey,
891
+ headers: auth.headers,
892
+ env: auth.env,
893
+ signal
894
+ };
895
+ if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
896
+ return options;
897
+ }
898
+ function formatError(error) {
899
+ return error instanceof Error ? error.message : String(error);
900
+ }
901
+ var SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
902
+
903
+ Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
904
+
905
+ // src/settings.ts
906
+ var BTW_SETTINGS_FILE = "pi-btw.json";
907
+ var DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES = true;
908
+ var MAX_SETTINGS_BYTES = 64 * 1024;
909
+ var mutationQueues = /* @__PURE__ */ new Map();
910
+ function btwSettingsPath() {
911
+ return join(getAgentDir(), BTW_SETTINGS_FILE);
912
+ }
913
+ function normalizeBtwSettings(value) {
914
+ if (!isSettingsDocument(value)) return void 0;
915
+ const settings = {};
916
+ if (Object.hasOwn(value, "model")) {
917
+ const model = Reflect.get(value, "model");
918
+ if (typeof model !== "string" || !parseBtwModelReference(model)) return void 0;
919
+ settings.model = model;
920
+ }
921
+ if (Object.hasOwn(value, "thinkingLevel")) {
922
+ const thinkingLevel = Reflect.get(value, "thinkingLevel");
923
+ if (!isBtwThinkingLevel(thinkingLevel)) return void 0;
924
+ settings.thinkingLevel = thinkingLevel;
925
+ }
926
+ if (Object.hasOwn(value, "rememberThinkingLevelChanges")) {
927
+ const remember = Reflect.get(value, "rememberThinkingLevelChanges");
928
+ if (typeof remember !== "boolean") return void 0;
929
+ settings.rememberThinkingLevelChanges = remember;
930
+ }
931
+ return settings;
932
+ }
933
+ function parseBtwModelReference(reference) {
934
+ if (/[\s\p{Cc}]/u.test(reference)) return void 0;
935
+ const separator = reference.indexOf("/");
936
+ if (separator <= 0 || separator === reference.length - 1) return void 0;
937
+ return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
938
+ }
939
+ function effectiveRememberThinkingLevelChanges(settings) {
940
+ return settings.rememberThinkingLevelChanges ?? DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES;
941
+ }
942
+ async function readBtwSettings(settingsPath = btwSettingsPath()) {
943
+ await awaitBtwSettingsWrites(settingsPath);
944
+ return readBtwSettingsUncoordinated(settingsPath);
945
+ }
946
+ function updateBtwSettings(patch, options = {}) {
947
+ const settingsPath = options.settingsPath ?? btwSettingsPath();
948
+ return enqueueMutation(settingsPath, async () => {
949
+ options.signal?.throwIfAborted();
950
+ const current = await readSettingsDocumentForUpdate(settingsPath);
951
+ const updated = applyBtwSettingsPatch(current, patch);
952
+ const settings = normalizeBtwSettings(updated);
953
+ if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
954
+ await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
955
+ return settings;
956
+ });
957
+ }
958
+ async function awaitBtwSettingsWrites(settingsPath = btwSettingsPath()) {
959
+ await mutationQueues.get(settingsPath);
960
+ }
961
+ function enqueueMutation(settingsPath, mutation) {
962
+ const previous = mutationQueues.get(settingsPath) ?? Promise.resolve();
963
+ const result = previous.then(mutation, mutation);
964
+ const settled = result.then(
965
+ () => void 0,
966
+ () => void 0
967
+ );
968
+ mutationQueues.set(settingsPath, settled);
969
+ void settled.finally(() => {
970
+ if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath);
971
+ });
972
+ return result;
973
+ }
974
+ async function readBtwSettingsUncoordinated(settingsPath) {
975
+ let contents;
976
+ try {
977
+ contents = await readSettingsContents(settingsPath);
978
+ } catch (error) {
979
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
980
+ return { kind: "invalid", reason: `${settingsPath}: ${formatError2(error)}` };
981
+ }
982
+ try {
983
+ const settings = normalizeBtwSettings(JSON.parse(contents));
984
+ return settings ? { kind: "loaded", settings } : { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
985
+ } catch {
986
+ return { kind: "invalid", reason: `${settingsPath}: invalid JSON` };
987
+ }
988
+ }
989
+ async function readSettingsDocumentForUpdate(settingsPath) {
990
+ let contents;
991
+ try {
992
+ contents = await readSettingsContents(settingsPath);
993
+ } catch (error) {
994
+ if (isNodeError(error) && error.code === "ENOENT") return {};
995
+ throw invalidSettingsError(settingsPath, formatError2(error));
996
+ }
997
+ let parsed;
998
+ try {
999
+ parsed = JSON.parse(contents);
1000
+ } catch {
1001
+ throw invalidSettingsError(settingsPath, "invalid JSON");
1002
+ }
1003
+ if (!isSettingsDocument(parsed) || !normalizeBtwSettings(parsed)) {
1004
+ throw invalidSettingsError(settingsPath, "invalid settings shape");
1005
+ }
1006
+ return parsed;
1007
+ }
1008
+ async function readSettingsContents(settingsPath) {
1009
+ const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
1010
+ const handle = await open(settingsPath, flags);
1011
+ try {
1012
+ const descriptorStats = await handle.stat();
1013
+ if (!descriptorStats.isFile()) throw new Error("settings path is not a regular file");
1014
+ if (descriptorStats.size > MAX_SETTINGS_BYTES) {
1015
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
1016
+ }
1017
+ const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
1018
+ let offset = 0;
1019
+ while (offset < buffer.byteLength) {
1020
+ const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
1021
+ if (bytesRead === 0) break;
1022
+ offset += bytesRead;
1023
+ }
1024
+ if (offset > MAX_SETTINGS_BYTES) {
1025
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
1026
+ }
1027
+ try {
1028
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
1029
+ buffer.subarray(0, offset)
1030
+ );
1031
+ } catch {
1032
+ throw new Error("settings file is not valid UTF-8");
1033
+ }
1034
+ } finally {
1035
+ await handle.close();
1036
+ }
1037
+ }
1038
+ async function publishSettings(settingsPath, document, signal, beforeRename) {
1039
+ signal?.throwIfAborted();
1040
+ const contents = `${JSON.stringify(document, null, 2)}
1041
+ `;
1042
+ if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) {
1043
+ throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`);
1044
+ }
1045
+ const directory = dirname(settingsPath);
1046
+ await mkdir(directory, { recursive: true });
1047
+ signal?.throwIfAborted();
1048
+ const temporaryPath = join(
1049
+ directory,
1050
+ `.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`
1051
+ );
1052
+ try {
1053
+ await writeFile(temporaryPath, contents, {
1054
+ encoding: "utf8",
1055
+ flag: "wx",
1056
+ mode: 384,
1057
+ signal
1058
+ });
1059
+ await beforeRename?.(temporaryPath, settingsPath);
1060
+ signal?.throwIfAborted();
1061
+ await rename(temporaryPath, settingsPath);
1062
+ } catch (error) {
1063
+ await rm(temporaryPath, { force: true }).catch(() => void 0);
1064
+ throw error;
1065
+ }
1066
+ }
1067
+ function applyBtwSettingsPatch(current, patch) {
1068
+ const updated = { ...current };
1069
+ if (Object.hasOwn(patch, "thinkingLevel")) {
1070
+ if (patch.thinkingLevel === void 0) delete updated.thinkingLevel;
1071
+ else updated.thinkingLevel = patch.thinkingLevel;
1072
+ }
1073
+ if (Object.hasOwn(patch, "rememberThinkingLevelChanges")) {
1074
+ updated.rememberThinkingLevelChanges = patch.rememberThinkingLevelChanges;
1075
+ }
1076
+ return updated;
1077
+ }
1078
+ function isSettingsDocument(value) {
1079
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1080
+ }
1081
+ function isBtwThinkingLevel(value) {
1082
+ return BTW_THINKING_LEVELS.includes(value);
1083
+ }
1084
+ function invalidSettingsError(settingsPath, reason) {
1085
+ return new Error(`pi-btw settings at ${settingsPath} are invalid: ${reason}`);
1086
+ }
1087
+ function isNodeError(error) {
1088
+ return error instanceof Error && "code" in error;
1089
+ }
1090
+ function formatError2(error) {
1091
+ return error instanceof Error ? error.message : String(error);
1092
+ }
1093
+
1094
+ // src/menu.ts
1095
+ var SAME_AS_MAIN_THREAD = "Same as main thread";
1096
+ async function showBtwCommandMenu(ctx, options) {
1097
+ if (ctx.mode !== "tui") return "closed";
1098
+ const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
1099
+ if (ctx.signal?.aborted) return "closed";
1100
+ const settingsPath = options.settingsPath ?? btwSettingsPath();
1101
+ const readSettings = options.readSettings ?? readBtwSettings;
1102
+ const updateSettings = options.updateSettings ?? updateBtwSettings;
1103
+ const levels = options.availableThinkingLevels.length > 0 ? [...options.availableThinkingLevels] : ["off"];
1104
+ const displaySettingsPath = sanitizeSingleLine(settingsPath);
1105
+ const resumeThreads = options.resumeThreads ?? [];
1106
+ let startSelected = false;
1107
+ let treeSelected = false;
1108
+ let resumedThreadId;
1109
+ const loadState = async () => {
1110
+ const loaded = await readSettings(settingsPath);
1111
+ if (loaded.kind === "invalid") {
1112
+ return { kind: "invalid", settings: {}, reason: loaded.reason };
1113
+ }
1114
+ return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
1115
+ };
1116
+ const currentMainThinkingLevel = clampToAvailableThinkingLevel(
1117
+ options.currentThinkingLevel,
1118
+ levels
1119
+ );
1120
+ const displayThinkingLevel = (settings) => settings.thinkingLevel === void 0 ? SAME_AS_MAIN_THREAD : clampToAvailableThinkingLevel(settings.thinkingLevel, levels);
1121
+ const displayThinkingSummary = (settings) => settings.thinkingLevel === void 0 ? `${SAME_AS_MAIN_THREAD} (currently ${currentMainThinkingLevel})` : displayThinkingLevel(settings);
1122
+ const displayRememberSummary = (settings) => {
1123
+ const value = effectiveRememberThinkingLevelChanges(settings) ? "On" : "Off";
1124
+ return settings.thinkingLevel === void 0 ? `${value} (fixed levels only)` : value;
1125
+ };
1126
+ const menu = defineMenu({
1127
+ start: "main",
1128
+ screens: {
1129
+ main: ({ state }) => ({
1130
+ kind: "actions",
1131
+ title: "Pi BTW",
1132
+ lines: [
1133
+ `Thinking: ${displayThinkingSummary(state.settings)} \xB7 Remember changes: ${displayRememberSummary(state.settings)}`
1134
+ ],
1135
+ items: [
1136
+ {
1137
+ id: "start",
1138
+ label: "Start side thread",
1139
+ description: "Open an empty side thread",
1140
+ action: "start"
1141
+ },
1142
+ {
1143
+ id: "start-tree",
1144
+ label: "Start from main thread tree\u2026",
1145
+ description: "Choose context without switching the main branch",
1146
+ action: "start-tree"
1147
+ },
1148
+ ...resumeThreads.length > 0 ? [
1149
+ {
1150
+ id: "resume",
1151
+ label: "Resume side thread",
1152
+ description: "Continue an in-memory side thread",
1153
+ to: "resume"
1154
+ }
1155
+ ] : [],
1156
+ {
1157
+ id: "settings",
1158
+ label: "Settings",
1159
+ description: "Choose pi-btw thinking level and fixed-level shortcut memory",
1160
+ to: state.kind === "invalid" ? "invalid" : "settings"
1161
+ }
1162
+ ],
1163
+ hint: "close"
1164
+ }),
1165
+ resume: () => ({
1166
+ kind: "choice",
1167
+ title: "Resume BTW side thread",
1168
+ enableSearch: true,
1169
+ items: resumeThreads.map((thread) => ({
1170
+ id: thread.id,
1171
+ label: thread.title,
1172
+ description: `${thread.questionCount} ${thread.questionCount === 1 ? "question" : "questions"}`
1173
+ })),
1174
+ action: "resume",
1175
+ viewportSize: 10,
1176
+ hint: "back"
1177
+ }),
1178
+ settings: ({ state }) => ({
1179
+ kind: "settings",
1180
+ title: "Pi BTW Settings",
1181
+ lines: [`User settings \xB7 ${displaySettingsPath}`],
1182
+ items: [
1183
+ {
1184
+ id: "thinkingLevel",
1185
+ label: "Thinking level",
1186
+ description: `Set the starting level for future pi-btw side threads. Currently ${currentMainThinkingLevel}.`,
1187
+ currentValue: displayThinkingLevel(state.settings),
1188
+ values: [SAME_AS_MAIN_THREAD, ...levels],
1189
+ action: "set-thinking"
1190
+ },
1191
+ {
1192
+ id: "rememberThinkingLevelChanges",
1193
+ label: "Remember thinking level changes",
1194
+ description: "Save shortcut changes for fixed thinking levels to pi-btw.json.",
1195
+ currentValue: effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off",
1196
+ values: ["On", "Off"],
1197
+ action: "set-remember"
1198
+ }
1199
+ ]
1200
+ }),
1201
+ invalid: ({ state }) => ({
1202
+ kind: "detail",
1203
+ title: "Pi BTW Settings \xB7 Read only",
1204
+ lines: [
1205
+ `Invalid settings file. Fix ${displaySettingsPath} before saving.`,
1206
+ sanitizeSingleLine(state.reason ?? "The settings file is invalid.")
1207
+ ],
1208
+ hint: "back"
1209
+ })
1210
+ },
1211
+ actions: {
1212
+ start: async () => {
1213
+ startSelected = true;
1214
+ return { kind: "close" };
1215
+ },
1216
+ "start-tree": async () => {
1217
+ treeSelected = true;
1218
+ return { kind: "close" };
1219
+ },
1220
+ resume: async ({ itemId }) => {
1221
+ if (!resumeThreads.some((thread) => thread.id === itemId)) {
1222
+ return { kind: "rejected" };
1223
+ }
1224
+ resumedThreadId = itemId;
1225
+ return { kind: "close" };
1226
+ },
1227
+ "set-thinking": async ({ value, signal }) => {
1228
+ if (!value) return { kind: "rejected" };
1229
+ const patch = value === SAME_AS_MAIN_THREAD ? { thinkingLevel: void 0 } : levels.includes(value) ? { thinkingLevel: value } : void 0;
1230
+ if (!patch) return { kind: "rejected" };
1231
+ try {
1232
+ await updateSettings(patch, { settingsPath, signal });
1233
+ if (signal.aborted) return { kind: "rejected" };
1234
+ notifySafely(ctx, `Pi BTW thinking level: ${value}.`, "info");
1235
+ return { kind: "stay" };
1236
+ } catch (error) {
1237
+ if (!signal.aborted) notifySaveFailure(ctx, error);
1238
+ return { kind: "rejected" };
1239
+ }
1240
+ },
1241
+ "set-remember": async ({ value, signal }) => {
1242
+ if (value !== "On" && value !== "Off") return { kind: "rejected" };
1243
+ try {
1244
+ await updateSettings(
1245
+ { rememberThinkingLevelChanges: value === "On" },
1246
+ { settingsPath, signal }
1247
+ );
1248
+ if (signal.aborted) return { kind: "rejected" };
1249
+ notifySafely(ctx, `Remember thinking level changes: ${value}.`, "info");
1250
+ return { kind: "stay" };
1251
+ } catch (error) {
1252
+ if (!signal.aborted) notifySaveFailure(ctx, error);
1253
+ return { kind: "rejected" };
1254
+ }
1255
+ }
1256
+ }
1257
+ });
1258
+ const result = await runBtwMenuPreservingEditor(
1259
+ ctx,
1260
+ (menuContext) => runMenu(menuContext, menu, { getState: loadState })
1261
+ );
1262
+ if (result.kind !== "closed" || result.reason !== "close") return "closed";
1263
+ if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
1264
+ if (treeSelected) return "tree";
1265
+ return startSelected ? "start" : "closed";
1266
+ }
1267
+ async function showBtwCustomPreservingEditor(ctx, factory) {
1268
+ let liveEditorText = ctx.ui.getEditorText();
1269
+ let completed = false;
1270
+ const result = await ctx.ui.custom(
1271
+ (tui, theme, keybindings, done) => factory(tui, theme, keybindings, (value) => {
1272
+ try {
1273
+ liveEditorText = ctx.ui.getEditorText();
1274
+ } catch {
1275
+ }
1276
+ completed = true;
1277
+ done(value);
1278
+ })
1279
+ );
1280
+ if (completed) {
1281
+ try {
1282
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
1283
+ } catch {
1284
+ }
1285
+ }
1286
+ return result;
1287
+ }
1288
+ async function runBtwMenuPreservingEditor(ctx, run) {
1289
+ let liveEditorText = ctx.ui.getEditorText();
1290
+ let completed = false;
1291
+ const ui = new Proxy(ctx.ui, {
1292
+ get(target, property) {
1293
+ if (property === "custom") {
1294
+ return (factory, customOptions) => target.custom(
1295
+ (tui, theme, keybindings, done) => factory(tui, theme, keybindings, (value2) => {
1296
+ try {
1297
+ liveEditorText = target.getEditorText();
1298
+ } catch {
1299
+ }
1300
+ completed = true;
1301
+ done(value2);
1302
+ }),
1303
+ customOptions
1304
+ );
1305
+ }
1306
+ const value = Reflect.get(target, property, target);
1307
+ return typeof value === "function" ? value.bind(target) : value;
1308
+ }
1309
+ });
1310
+ const result = await run({ mode: ctx.mode, hasUI: ctx.hasUI, ui });
1311
+ if (result.kind !== "stale" && completed) {
1312
+ try {
1313
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
1314
+ } catch {
1315
+ }
1316
+ }
1317
+ return result;
1318
+ }
1319
+ function clampToAvailableThinkingLevel(requested, available) {
1320
+ if (available.includes(requested)) return requested;
1321
+ const requestedIndex = BTW_THINKING_LEVELS.indexOf(requested);
1322
+ for (let index = requestedIndex; index < BTW_THINKING_LEVELS.length; index += 1) {
1323
+ const candidate = BTW_THINKING_LEVELS[index];
1324
+ if (candidate && available.includes(candidate)) return candidate;
1325
+ }
1326
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
1327
+ const candidate = BTW_THINKING_LEVELS[index];
1328
+ if (candidate && available.includes(candidate)) return candidate;
1329
+ }
1330
+ return available[0] ?? "off";
1331
+ }
1332
+ function notifySaveFailure(ctx, error) {
1333
+ notifySafely(
1334
+ ctx,
1335
+ `Pi BTW settings were not saved; the previous value remains active: ${formatError3(error)}`,
1336
+ "error"
1337
+ );
1338
+ }
1339
+ function notifySafely(ctx, message, level) {
1340
+ try {
1341
+ ctx.ui.notify(sanitizeSingleLine(message), level);
1342
+ } catch {
1343
+ }
1344
+ }
1345
+ function formatError3(error) {
1346
+ return error instanceof Error ? error.message : String(error);
1347
+ }
1348
+
1349
+ // src/main-tree-picker.ts
1350
+ var MainThreadTreePickerComponent = class {
1351
+ constructor(selector, onClose) {
1352
+ this.selector = selector;
1353
+ this.onClose = onClose;
1354
+ }
1355
+ selector;
1356
+ onClose;
1357
+ get focused() {
1358
+ return this.selector.focused;
1359
+ }
1360
+ set focused(value) {
1361
+ this.selector.focused = value;
1362
+ }
1363
+ get wantsKeyRelease() {
1364
+ return this.selector.wantsKeyRelease;
1365
+ }
1366
+ render(width) {
1367
+ return this.selector.render(width);
1368
+ }
1369
+ handleInput(data) {
1370
+ if (matchesKey2(data, Key2.ctrl("c"))) {
1371
+ this.onClose();
1372
+ return;
1373
+ }
1374
+ this.selector.handleInput?.(data);
1375
+ }
1376
+ invalidate() {
1377
+ this.selector.invalidate();
1378
+ }
1379
+ dispose() {
1380
+ this.selector.dispose?.();
1381
+ this.onClose();
1382
+ }
1383
+ };
1384
+ async function pickMainEntry(pi, ctx, dependencies = {}) {
1385
+ let rawTree;
1386
+ let currentLeafId;
1387
+ try {
1388
+ rawTree = ctx.sessionManager.getTree();
1389
+ currentLeafId = ctx.sessionManager.getLeafId();
1390
+ } catch {
1391
+ return { kind: "closed" };
1392
+ }
1393
+ if (rawTree.length === 0) {
1394
+ notifySafely2(ctx, "No main-thread entries are available", "warning");
1395
+ return { kind: "back" };
1396
+ }
1397
+ const tree = sanitizeTreeForDisplay(rawTree);
1398
+ const rawCopyText = collectRawCopyText(rawTree);
1399
+ const savedLabels = collectSavedLabels(rawTree);
1400
+ const createSelector = dependencies.createSelector ?? createNativeTreeSelector;
1401
+ const copy = dependencies.copyToClipboard ?? copyText;
1402
+ const copyControllers = /* @__PURE__ */ new Set();
1403
+ const copyTasks = /* @__PURE__ */ new Set();
1404
+ const abortCopies = () => {
1405
+ for (const controller of copyControllers) {
1406
+ controller.abort(new Error("The main-thread tree picker closed"));
1407
+ }
1408
+ };
1409
+ const result = await showBtwCustomPreservingEditor(
1410
+ ctx,
1411
+ (tui, _theme, _keybindings, done) => {
1412
+ let settled = false;
1413
+ let selector;
1414
+ const finish = (value) => {
1415
+ if (settled) return;
1416
+ settled = true;
1417
+ abortCopies();
1418
+ done(value);
1419
+ };
1420
+ const onCopy = (entryId, displayText) => {
1421
+ if (settled) return;
1422
+ const text = entryId ? rawCopyText.get(entryId) : displayText;
1423
+ if (!text) {
1424
+ notifySafely2(ctx, "Selected entry has no text to copy", "warning");
1425
+ return;
1426
+ }
1427
+ const controller = new AbortController();
1428
+ copyControllers.add(controller);
1429
+ let operation;
1430
+ try {
1431
+ operation = copy(text, controller.signal);
1432
+ } catch (error) {
1433
+ operation = Promise.reject(error);
1434
+ }
1435
+ let task;
1436
+ task = operation.then(() => {
1437
+ if (!settled) notifySafely2(ctx, "Copied selected message", "info");
1438
+ }).catch((error) => {
1439
+ if (!settled && !controller.signal.aborted) {
1440
+ notifySafely2(ctx, `Could not copy selected message: ${formatError4(error)}`, "error");
1441
+ }
1442
+ }).finally(() => {
1443
+ copyControllers.delete(controller);
1444
+ copyTasks.delete(task);
1445
+ });
1446
+ copyTasks.add(task);
1447
+ };
1448
+ const restoreLabel = (entryId) => {
1449
+ const previous = savedLabels.get(entryId);
1450
+ selector?.setViewLabel?.(entryId, previous?.label, previous?.labelTimestamp);
1451
+ tui.requestRender();
1452
+ };
1453
+ const onLabelChange = (entryId, label) => {
1454
+ if (settled) return;
1455
+ try {
1456
+ if (!ctx.sessionManager.getEntry(entryId)) {
1457
+ restoreLabel(entryId);
1458
+ notifySafely2(ctx, "The selected main-thread entry is no longer available", "warning");
1459
+ return;
1460
+ }
1461
+ const persistedLabel = label === void 0 ? void 0 : sanitizeSingleLine(label);
1462
+ pi.setLabel(entryId, persistedLabel);
1463
+ savedLabels.set(entryId, { label: persistedLabel });
1464
+ selector?.setViewLabel?.(entryId, persistedLabel);
1465
+ tui.requestRender();
1466
+ } catch (error) {
1467
+ restoreLabel(entryId);
1468
+ notifySafely2(ctx, `Could not update tree label: ${formatError4(error)}`, "error");
1469
+ }
1470
+ };
1471
+ selector = createSelector({
1472
+ tree,
1473
+ currentLeafId,
1474
+ terminalRows: tui.terminal.rows,
1475
+ onSelect: (entryId) => finish({ kind: "selected", entryId }),
1476
+ onCancel: () => finish({ kind: "back" }),
1477
+ onCopy,
1478
+ onLabelChange
1479
+ });
1480
+ return new MainThreadTreePickerComponent(selector, () => finish({ kind: "closed" }));
1481
+ }
1482
+ );
1483
+ abortCopies();
1484
+ await Promise.allSettled([...copyTasks]);
1485
+ return result ?? { kind: "closed" };
1486
+ }
1487
+ function createNativeTreeSelector(options) {
1488
+ const selector = new TreeSelectorComponent(
1489
+ options.tree,
1490
+ options.currentLeafId,
1491
+ options.terminalRows,
1492
+ options.onSelect,
1493
+ options.onCancel,
1494
+ options.onLabelChange
1495
+ );
1496
+ selector.onCopy = (displayText) => options.onCopy(selector.getTreeList().getSelectedNode()?.entry.id, displayText);
1497
+ const result = selector;
1498
+ result.setViewLabel = (entryId, label, labelTimestamp) => selector.getTreeList().updateNodeLabel(entryId, label, labelTimestamp);
1499
+ return result;
1500
+ }
1501
+ function sanitizeTreeForDisplay(tree) {
1502
+ return tree.map((node) => {
1503
+ const result = {
1504
+ entry: sanitizeEntryForDisplay(node.entry),
1505
+ children: sanitizeTreeForDisplay(node.children)
1506
+ };
1507
+ if (node.label !== void 0) result.label = sanitizeSingleLine(node.label);
1508
+ if (node.labelTimestamp !== void 0) {
1509
+ result.labelTimestamp = sanitizeSingleLine(node.labelTimestamp);
1510
+ }
1511
+ return result;
1512
+ });
1513
+ }
1514
+ function sanitizeEntryForDisplay(entry) {
1515
+ switch (entry.type) {
1516
+ case "message": {
1517
+ const message = { ...entry.message };
1518
+ if ("content" in entry.message)
1519
+ message.content = sanitizeDisplayContent(entry.message.content);
1520
+ for (const key of ["role", "errorMessage", "command", "toolName"]) {
1521
+ const value = message[key];
1522
+ if (typeof value === "string") message[key] = sanitizeSingleLine(value);
1523
+ }
1524
+ return { ...entry, message };
1525
+ }
1526
+ case "custom_message":
1527
+ return {
1528
+ ...entry,
1529
+ customType: sanitizeSingleLine(entry.customType),
1530
+ content: sanitizeDisplayContent(entry.content)
1531
+ };
1532
+ case "compaction":
1533
+ return { ...entry, summary: sanitizeSingleLine(entry.summary) };
1534
+ case "branch_summary":
1535
+ return { ...entry, summary: sanitizeSingleLine(entry.summary) };
1536
+ case "model_change":
1537
+ return {
1538
+ ...entry,
1539
+ provider: sanitizeSingleLine(entry.provider),
1540
+ modelId: sanitizeSingleLine(entry.modelId)
1541
+ };
1542
+ case "thinking_level_change":
1543
+ return { ...entry, thinkingLevel: sanitizeSingleLine(entry.thinkingLevel) };
1544
+ case "custom":
1545
+ return { ...entry, customType: sanitizeSingleLine(entry.customType) };
1546
+ case "label":
1547
+ return {
1548
+ ...entry,
1549
+ label: entry.label === void 0 ? void 0 : sanitizeSingleLine(entry.label)
1550
+ };
1551
+ case "session_info":
1552
+ return {
1553
+ ...entry,
1554
+ name: entry.name === void 0 ? void 0 : sanitizeSingleLine(entry.name)
1555
+ };
1556
+ }
1557
+ }
1558
+ function sanitizeDisplayContent(content) {
1559
+ if (typeof content === "string") return sanitizeSingleLine(content);
1560
+ if (!Array.isArray(content)) return content;
1561
+ return content.map((block) => {
1562
+ if (block === null || typeof block !== "object" || !("type" in block)) return block;
1563
+ if (block.type === "text" && "text" in block && typeof block.text === "string") {
1564
+ return { ...block, text: sanitizeSingleLine(block.text) };
1565
+ }
1566
+ if (block.type === "toolCall") {
1567
+ const copy = { ...block };
1568
+ if (typeof copy.name === "string") copy.name = sanitizeSingleLine(copy.name);
1569
+ copy.arguments = sanitizeToolArguments(copy.arguments, /* @__PURE__ */ new WeakMap());
1570
+ return copy;
1571
+ }
1572
+ return block;
1573
+ });
1574
+ }
1575
+ function sanitizeToolArguments(value, seen) {
1576
+ if (typeof value === "string") return sanitizeSingleLine(value);
1577
+ if (value === null || typeof value !== "object") return value;
1578
+ const existing = seen.get(value);
1579
+ if (existing !== void 0) return existing;
1580
+ if (Array.isArray(value)) {
1581
+ const result2 = [];
1582
+ seen.set(value, result2);
1583
+ for (const item of value) result2.push(sanitizeToolArguments(item, seen));
1584
+ return result2;
1585
+ }
1586
+ const result = {};
1587
+ seen.set(value, result);
1588
+ for (const [key, item] of Object.entries(value)) {
1589
+ result[key] = sanitizeToolArguments(item, seen);
1590
+ }
1591
+ return result;
1592
+ }
1593
+ function collectRawCopyText(tree) {
1594
+ const result = /* @__PURE__ */ new Map();
1595
+ const visit = (nodes) => {
1596
+ for (const node of nodes) {
1597
+ const text = getRawCopyText(node.entry);
1598
+ if (text !== void 0) result.set(node.entry.id, text);
1599
+ visit(node.children);
1600
+ }
1601
+ };
1602
+ visit(tree);
1603
+ return result;
1604
+ }
1605
+ function getRawCopyText(entry) {
1606
+ let text;
1607
+ if (entry.type === "message") {
1608
+ if (entry.message.role === "bashExecution") text = entry.message.command;
1609
+ else if ("content" in entry.message) {
1610
+ text = extractRawText(entry.message.content);
1611
+ if (!text && entry.message.role === "assistant") text = entry.message.errorMessage;
1612
+ }
1613
+ } else if (entry.type === "custom_message") text = extractRawText(entry.content);
1614
+ else if (entry.type === "compaction" || entry.type === "branch_summary") text = entry.summary;
1615
+ return text?.trim() ? text : void 0;
1616
+ }
1617
+ function extractRawText(content) {
1618
+ if (typeof content === "string") return content;
1619
+ if (!Array.isArray(content)) return "";
1620
+ return content.filter(
1621
+ (block) => block !== null && typeof block === "object" && "type" in block && block.type === "text" && "text" in block && typeof block.text === "string"
1622
+ ).map((block) => block.text).join("");
1623
+ }
1624
+ function collectSavedLabels(tree) {
1625
+ const result = /* @__PURE__ */ new Map();
1626
+ const visit = (nodes) => {
1627
+ for (const node of nodes) {
1628
+ result.set(node.entry.id, {
1629
+ label: node.label === void 0 ? void 0 : sanitizeSingleLine(node.label),
1630
+ labelTimestamp: node.labelTimestamp === void 0 ? void 0 : sanitizeSingleLine(node.labelTimestamp)
1631
+ });
1632
+ visit(node.children);
1633
+ }
1634
+ };
1635
+ visit(tree);
1636
+ return result;
1637
+ }
1638
+ async function copyText(text, signal) {
1639
+ signal.throwIfAborted();
1640
+ await copyToClipboard(text);
1641
+ signal.throwIfAborted();
1642
+ }
1643
+ function notifySafely2(ctx, message, level) {
1644
+ try {
1645
+ ctx.ui.notify(sanitizeSingleLine(message), level);
1646
+ } catch {
1647
+ }
1648
+ }
1649
+ function formatError4(error) {
1650
+ return error instanceof Error ? error.message : String(error);
1651
+ }
1652
+
1653
+ // src/transcript-pager.ts
1654
+ import {
1655
+ AssistantMessageComponent,
1656
+ getMarkdownTheme,
1657
+ UserMessageComponent
1658
+ } from "@earendil-works/pi-coding-agent";
1659
+ import {
1660
+ CURSOR_MARKER,
1661
+ Editor,
1662
+ Key as Key3,
1663
+ Loader,
1664
+ Markdown,
1665
+ matchesKey as matchesKey3,
1666
+ ScrollView,
1667
+ truncateToWidth as truncateToWidth3,
1668
+ VStack,
1669
+ visibleWidth as visibleWidth2
1670
+ } from "@earendil-works/pi-tui";
1671
+ var TRANSCRIPT_CHROME_LINES = 2;
1672
+ var MAX_STEERING_DISPLAY_LINES = 3;
1673
+ var OSC133_MARKERS = ["\x1B]133;A\x07", "\x1B]133;B\x07", "\x1B]133;C\x07"];
1674
+ var RESERVED_APP_LINES = 3;
1675
+ var PreservingScrollView = class extends ScrollView {
1676
+ updateLayout(contentHeight, viewportHeight, requestRender) {
1677
+ const preserveManualPosition = !this.isFollowingEnd;
1678
+ super.updateLayout(contentHeight, viewportHeight, requestRender);
1679
+ if (preserveManualPosition && this.isFollowingEnd) {
1680
+ this.scrollTo(this.scrollTop, { disableFollow: true });
1681
+ }
1682
+ }
1683
+ };
1684
+ var BtwTranscriptPager = class {
1685
+ constructor(tui, theme, turns, onAction, options = {}) {
1686
+ this.tui = tui;
1687
+ this.theme = theme;
1688
+ this.onAction = onAction;
1689
+ this.options = options;
1690
+ this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
1691
+ this.canBringToMain = turns.some((turn) => turn.kind === "answered");
1692
+ this.thinkingLevel = options.thinking?.level;
1693
+ const editorTheme = {
1694
+ borderColor: (text) => this.theme.fg("accent", text),
1695
+ selectList: {
1696
+ selectedPrefix: (text) => this.theme.fg("accent", text),
1697
+ selectedText: (text) => this.theme.fg("accent", text),
1698
+ description: (text) => this.theme.fg("muted", text),
1699
+ scrollInfo: (text) => this.theme.fg("dim", text),
1700
+ noMatch: (text) => this.theme.fg("warning", text)
1701
+ }
1702
+ };
1703
+ this.editor = new Editor(this.tui, editorTheme);
1704
+ if (options.initialQuestion) this.editor.setText(options.initialQuestion);
1705
+ this.editor.onChange = () => {
1706
+ this.warning = void 0;
1707
+ };
1708
+ this.editor.onSubmit = (text) => {
1709
+ const question = text.trim();
1710
+ if (!question) {
1711
+ this.warning = "Question cannot be empty";
1712
+ return;
1713
+ }
1714
+ this.finished = true;
1715
+ this.onAction({ kind: "submit", question });
1716
+ };
1717
+ const transcript = this.createTranscriptComponent();
1718
+ this.scrollView = new PreservingScrollView(transcript, {
1719
+ follow: options.startAtBottom ? "end" : "none",
1720
+ primary: true
1721
+ });
1722
+ this.layoutRoot = new VStack([
1723
+ { component: this.createHeaderComponent(), basis: 1, shrink: 0, minSize: 1 },
1724
+ { component: this.scrollView, basis: 0, grow: 1, minSize: 0 },
1725
+ { component: this.createFooterComponent(), basis: 1, shrink: 0, minSize: 1 },
1726
+ { component: this.editor, basis: "auto", shrink: 1, minSize: 0 }
1727
+ ]);
1728
+ }
1729
+ tui;
1730
+ theme;
1731
+ onAction;
1732
+ options;
1733
+ transcriptComponents;
1734
+ editor;
1735
+ canBringToMain;
1736
+ scrollView;
1737
+ layoutRoot;
1738
+ lastContentLineCount = 0;
1739
+ warning;
1740
+ finished = false;
1741
+ isFocused = false;
1742
+ thinkingLevel;
1743
+ get focused() {
1744
+ return this.isFocused;
1745
+ }
1746
+ set focused(value) {
1747
+ this.isFocused = value;
1748
+ this.editor.focused = value;
1749
+ }
1750
+ getFullscreenLayout() {
1751
+ return this.layoutRoot;
1752
+ }
1753
+ render(width) {
1754
+ const safeWidth = Math.max(1, width);
1755
+ const editorLines = this.editor.render(safeWidth);
1756
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
1757
+ const viewportHeight = Math.max(
1758
+ 0,
1759
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES
1760
+ );
1761
+ const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
1762
+ this.lastContentLineCount = contentLines.length;
1763
+ this.scrollView.updateLayout(
1764
+ contentLines.length,
1765
+ viewportHeight,
1766
+ () => this.tui.requestRender()
1767
+ );
1768
+ return fitComposerLayout(
1769
+ renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
1770
+ contentLines.slice(this.scrollView.scrollTop, this.scrollView.scrollTop + viewportHeight),
1771
+ this.renderFooter(safeWidth),
1772
+ editorLines,
1773
+ availableRows
1774
+ );
1775
+ }
1776
+ handleInput(data) {
1777
+ if (this.finished) return;
1778
+ if (matchesKey3(data, Key3.ctrl("c"))) {
1779
+ this.finished = true;
1780
+ this.onAction({ kind: "close" });
1781
+ return;
1782
+ }
1783
+ if (this.canBringToMain && matchesKey3(data, Key3.ctrl("r"))) {
1784
+ this.finished = true;
1785
+ this.onAction({ kind: "bringToMain", questionDraft: this.editor.getExpandedText() });
1786
+ return;
1787
+ }
1788
+ const thinking = this.options.thinking;
1789
+ if (thinking && thinking.levels.length > 1 && thinking.keybindings.matches(data, "app.thinking.cycle")) {
1790
+ const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
1791
+ const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
1792
+ if (nextLevel) {
1793
+ this.thinkingLevel = nextLevel;
1794
+ thinking.onChange(nextLevel);
1795
+ this.warning = void 0;
1796
+ this.tui.requestRender();
1797
+ }
1798
+ return;
1799
+ }
1800
+ if (matchesKey3(data, Key3.pageUp)) {
1801
+ this.scrollView.scrollBy(-Math.max(1, this.scrollView.viewportHeight));
1802
+ this.tui.requestRender();
1803
+ return;
1804
+ }
1805
+ if (matchesKey3(data, Key3.pageDown)) {
1806
+ this.scrollView.scrollBy(Math.max(1, this.scrollView.viewportHeight));
1807
+ this.tui.requestRender();
1808
+ return;
1809
+ }
1810
+ this.editor.handleInput(data);
1811
+ if (!this.finished) this.tui.requestRender();
1812
+ }
1813
+ invalidate() {
1814
+ this.layoutRoot.invalidate();
1815
+ }
1816
+ dispose() {
1817
+ if (this.finished) return;
1818
+ this.finished = true;
1819
+ this.onAction({ kind: "close" });
1820
+ }
1821
+ renderFooter(width) {
1822
+ if (this.warning) {
1823
+ const warning = width < 32 ? "Empty \u2022 Ctrl+C" : `${this.warning} \u2022 Ctrl+C exit`;
1824
+ return truncateToWidth3(this.theme.fg("warning", warning), width);
1825
+ }
1826
+ const scrollable = this.getMaxScrollOffset() > 0;
1827
+ const thinking = this.options.thinking;
1828
+ const cycleHint = thinking && thinking.levels.length > 1 && this.thinkingLevel ? ` \u2022 thinking ${this.thinkingLevel} \u2022 ${thinkingKeyLabel(thinking.keybindings)} cycle` : "";
1829
+ const base = this.canBringToMain ? "btw \u2022 Enter send \u2022 Ctrl+R bring to main \u2022 Ctrl+C exit" : "btw \u2022 Enter send \u2022 Ctrl+C exit";
1830
+ const fullBase = `${base}${cycleHint}`;
1831
+ const fallbackBase = "btw \u2022 Enter \u2022 Ctrl+C";
1832
+ const compactBase = this.canBringToMain ? "btw \u2022 Enter \u2022 Ctrl+R \u2022 Ctrl+C" : fallbackBase;
1833
+ const compactWithThinking = `${compactBase}${cycleHint}`;
1834
+ let hints = visibleWidth2(fullBase) <= width ? fullBase : visibleWidth2(compactWithThinking) <= width ? compactWithThinking : visibleWidth2(compactBase) <= width ? compactBase : fallbackBase;
1835
+ if (scrollable) {
1836
+ const history = ` \u2022 ${this.scrollView.scrollTop > 0 ? "\u2191 older" : "\u2193 newer"} \u2022 PgUp/PgDn history`;
1837
+ const compactHistory = " \u2022 PgUp/PgDn";
1838
+ const compactScrollable = this.canBringToMain ? "Enter \u2022 Ctrl+R \u2022 Ctrl+C \u2022 PgUp/PgDn" : `${fallbackBase}${compactHistory}`;
1839
+ if (visibleWidth2(`${hints}${history}`) <= width) {
1840
+ hints += history;
1841
+ } else if (visibleWidth2(`${compactBase}${history}`) <= width) {
1842
+ hints = `${compactBase}${history}`;
1843
+ } else if (visibleWidth2(`${hints}${compactHistory}`) <= width) {
1844
+ hints += compactHistory;
1845
+ } else if (visibleWidth2(`${compactBase}${compactHistory}`) <= width) {
1846
+ hints = `${compactBase}${compactHistory}`;
1847
+ } else if (visibleWidth2(compactScrollable) <= width) {
1848
+ hints = compactScrollable;
1849
+ }
1850
+ }
1851
+ return truncateToWidth3(this.theme.fg("muted", hints), width);
1852
+ }
1853
+ createHeaderComponent() {
1854
+ return {
1855
+ render: (width) => [renderSideThreadHeader(width, this.theme, this.thinkingLevel)],
1856
+ invalidate() {
1857
+ }
1858
+ };
1859
+ }
1860
+ createTranscriptComponent() {
1861
+ return {
1862
+ render: (width) => {
1863
+ const lines = renderTranscriptLines(this.transcriptComponents, width);
1864
+ this.lastContentLineCount = lines.length;
1865
+ return lines;
1866
+ },
1867
+ invalidate: () => {
1868
+ for (const component of this.transcriptComponents) component.invalidate();
1869
+ }
1870
+ };
1871
+ }
1872
+ createFooterComponent() {
1873
+ return {
1874
+ render: (width) => [this.renderFooter(width)],
1875
+ invalidate() {
1876
+ }
1877
+ };
1878
+ }
1879
+ getMaxScrollOffset() {
1880
+ return Math.max(0, this.lastContentLineCount - this.scrollView.viewportHeight);
1881
+ }
1882
+ };
1883
+ var BtwAnsweringView = class {
1884
+ constructor(tui, theme, turns, pendingQuestion, onCancel, thinkingLevel, options = {}) {
1885
+ this.tui = tui;
1886
+ this.theme = theme;
1887
+ this.onCancel = onCancel;
1888
+ this.options = options;
1889
+ this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
1890
+ this.thinkingLevel = options.steering?.thinking?.level ?? thinkingLevel;
1891
+ this.loader = new Loader(
1892
+ this.tui,
1893
+ (text) => this.theme.fg("accent", text),
1894
+ (text) => this.theme.fg("muted", text),
1895
+ "Answering\u2026"
1896
+ );
1897
+ if (options.steering) {
1898
+ const editorTheme = {
1899
+ borderColor: (text) => this.theme.fg("accent", text),
1900
+ selectList: {
1901
+ selectedPrefix: (text) => this.theme.fg("accent", text),
1902
+ selectedText: (text) => this.theme.fg("accent", text),
1903
+ description: (text) => this.theme.fg("muted", text),
1904
+ scrollInfo: (text) => this.theme.fg("dim", text),
1905
+ noMatch: (text) => this.theme.fg("warning", text)
1906
+ }
1907
+ };
1908
+ this.editor = new Editor(this.tui, editorTheme);
1909
+ this.editor.onChange = () => {
1910
+ this.warning = void 0;
1911
+ };
1912
+ this.editor.onSubmit = (text) => {
1913
+ const question = text.trim();
1914
+ if (!question) {
1915
+ this.warning = "Question cannot be empty";
1916
+ return;
1917
+ }
1918
+ options.steering?.onSubmit(question);
1919
+ this.warning = void 0;
1920
+ };
1921
+ }
1922
+ const transcript = this.createTranscriptComponent();
1923
+ this.scrollView = new PreservingScrollView(transcript, { follow: "end", primary: true });
1924
+ this.layoutRoot = new VStack([
1925
+ { component: this.createHeaderComponent(), basis: 1, shrink: 0, minSize: 1 },
1926
+ { component: this.scrollView, basis: 0, grow: 1, minSize: 0 },
1927
+ {
1928
+ component: this.createSteeringComponent(),
1929
+ basis: "auto",
1930
+ shrink: 1,
1931
+ minSize: 0,
1932
+ maxSize: MAX_STEERING_DISPLAY_LINES
1933
+ },
1934
+ { component: this.createFooterComponent(), basis: 1, shrink: 0, minSize: 1 },
1935
+ ...this.editor ? [{ component: this.editor, basis: "auto", shrink: 1, minSize: 0 }] : []
1936
+ ]);
1937
+ }
1938
+ tui;
1939
+ theme;
1940
+ onCancel;
1941
+ options;
1942
+ transcriptComponents;
1943
+ loader;
1944
+ editor;
1945
+ controller = new AbortController();
1946
+ scrollView;
1947
+ layoutRoot;
1948
+ lastContentLineCount = 0;
1949
+ warning;
1950
+ finished = false;
1951
+ isFocused = false;
1952
+ thinkingLevel;
1953
+ get focused() {
1954
+ return this.isFocused;
1955
+ }
1956
+ set focused(value) {
1957
+ this.isFocused = value;
1958
+ if (this.editor) this.editor.focused = value;
1959
+ }
1960
+ get signal() {
1961
+ return this.controller.signal;
1962
+ }
1963
+ getFullscreenLayout() {
1964
+ return this.layoutRoot;
1965
+ }
1966
+ render(width) {
1967
+ const safeWidth = Math.max(1, width);
1968
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
1969
+ const editorLines = this.editor?.render(safeWidth) ?? [];
1970
+ const steeringCapacity = Math.max(
1971
+ 0,
1972
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES
1973
+ );
1974
+ const steeringLines = renderSteeringLines(
1975
+ this.options.steering?.questions ?? [],
1976
+ safeWidth,
1977
+ this.theme,
1978
+ Math.min(MAX_STEERING_DISPLAY_LINES, steeringCapacity)
1979
+ );
1980
+ const viewportHeight = Math.max(
1981
+ 0,
1982
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES - steeringLines.length
1983
+ );
1984
+ const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
1985
+ this.lastContentLineCount = contentLines.length;
1986
+ this.scrollView.updateLayout(
1987
+ contentLines.length,
1988
+ viewportHeight,
1989
+ () => this.tui.requestRender()
1990
+ );
1991
+ return fitComposerLayout(
1992
+ renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
1993
+ contentLines.slice(this.scrollView.scrollTop, this.scrollView.scrollTop + viewportHeight),
1994
+ this.renderFooter(safeWidth),
1995
+ editorLines,
1996
+ availableRows,
1997
+ steeringLines
1998
+ );
1999
+ }
2000
+ handleInput(data) {
2001
+ if (this.finished) return;
2002
+ if (matchesKey3(data, Key3.ctrl("c"))) {
2003
+ this.finished = true;
2004
+ this.loader.stop();
2005
+ this.controller.abort();
2006
+ this.onCancel();
2007
+ return;
2008
+ }
2009
+ const thinking = this.options.steering?.thinking;
2010
+ if (thinking && thinking.levels.length > 1 && thinking.keybindings.matches(data, "app.thinking.cycle")) {
2011
+ const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
2012
+ const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
2013
+ if (nextLevel) {
2014
+ this.thinkingLevel = nextLevel;
2015
+ thinking.onChange(nextLevel);
2016
+ this.warning = void 0;
2017
+ this.tui.requestRender();
2018
+ }
2019
+ return;
2020
+ }
2021
+ if (matchesKey3(data, Key3.pageUp)) {
2022
+ this.scrollView.scrollBy(-Math.max(1, this.scrollView.viewportHeight));
2023
+ this.tui.requestRender();
2024
+ return;
2025
+ }
2026
+ if (matchesKey3(data, Key3.pageDown)) {
2027
+ this.scrollView.scrollBy(Math.max(1, this.scrollView.viewportHeight));
2028
+ this.tui.requestRender();
2029
+ return;
2030
+ }
2031
+ this.editor?.handleInput(data);
2032
+ this.tui.requestRender();
2033
+ }
2034
+ invalidate() {
2035
+ this.layoutRoot.invalidate();
2036
+ }
2037
+ finish() {
2038
+ this.finished = true;
2039
+ this.loader.stop();
2040
+ }
2041
+ dispose() {
2042
+ if (this.finished) {
2043
+ this.loader.stop();
2044
+ this.controller.abort();
2045
+ return;
2046
+ }
2047
+ this.finished = true;
2048
+ this.loader.stop();
2049
+ this.controller.abort();
2050
+ this.onCancel();
2051
+ }
2052
+ renderFooter(width) {
2053
+ if (this.warning) {
2054
+ const warning = width < 32 ? "Empty \u2022 Ctrl+C" : `${this.warning} \u2022 Ctrl+C cancel`;
2055
+ return truncateToWidth3(this.theme.fg("warning", warning), width);
2056
+ }
2057
+ const baseHint = this.editor ? "Enter steer \u2022 Ctrl+C cancel" : "Ctrl+C cancel";
2058
+ const thinking = this.options.steering?.thinking;
2059
+ const cycleHint = thinking && thinking.levels.length > 1 && this.thinkingLevel ? ` \u2022 thinking ${this.thinkingLevel} \u2022 ${thinkingKeyLabel(thinking.keybindings)} cycle` : "";
2060
+ const scrollHint = this.getMaxScrollOffset() > 0 ? " \u2022 PgUp/PgDn history" : "";
2061
+ const hints = `${baseHint}${cycleHint}${scrollHint}`;
2062
+ const compactHints = this.editor ? "Enter \u2022 Ctrl+C" : "Ctrl+C";
2063
+ const selectedHints = visibleWidth2(hints) <= width ? hints : compactHints;
2064
+ const loaderWidth = Math.max(1, width - visibleWidth2(selectedHints) - 3);
2065
+ const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering\u2026";
2066
+ return truncateToWidth3(`${loaderLine} \u2022 ${this.theme.fg("muted", selectedHints)}`, width);
2067
+ }
2068
+ createHeaderComponent() {
2069
+ return {
2070
+ render: (width) => [renderSideThreadHeader(width, this.theme, this.thinkingLevel)],
2071
+ invalidate() {
2072
+ }
2073
+ };
2074
+ }
2075
+ createTranscriptComponent() {
2076
+ return {
2077
+ render: (width) => {
2078
+ const lines = renderTranscriptLines(this.transcriptComponents, width);
2079
+ this.lastContentLineCount = lines.length;
2080
+ return lines;
2081
+ },
2082
+ invalidate: () => {
2083
+ for (const component of this.transcriptComponents) component.invalidate();
2084
+ }
2085
+ };
2086
+ }
2087
+ createSteeringComponent() {
2088
+ return {
2089
+ render: (width) => renderSteeringLines(
2090
+ this.options.steering?.questions ?? [],
2091
+ width,
2092
+ this.theme,
2093
+ MAX_STEERING_DISPLAY_LINES
2094
+ ),
2095
+ invalidate() {
2096
+ }
2097
+ };
2098
+ }
2099
+ createFooterComponent() {
2100
+ return {
2101
+ render: (width) => [this.renderFooter(width)],
2102
+ invalidate: () => this.loader.invalidate()
2103
+ };
2104
+ }
2105
+ getMaxScrollOffset() {
2106
+ return Math.max(0, this.lastContentLineCount - this.scrollView.viewportHeight);
2107
+ }
2108
+ };
2109
+ function buildTranscriptComponents(turns, theme, pendingQuestion) {
2110
+ const components = turns.flatMap((turn) => {
2111
+ const question = new UserMessageComponent(
2112
+ escapeTerminalControls2(turn.question),
2113
+ getMarkdownTheme(),
2114
+ 1
2115
+ );
2116
+ if (turn.kind === "error") {
2117
+ const error = new Markdown(
2118
+ `Error: ${escapeTerminalControls2(turn.answer)}`,
2119
+ 1,
2120
+ 1,
2121
+ getMarkdownTheme(),
2122
+ { color: (text) => theme.fg("error", text) }
2123
+ );
2124
+ return [question, error];
2125
+ }
2126
+ const response = {
2127
+ ...turn.response,
2128
+ content: [{ type: "text", text: escapeTerminalControls2(turn.answer) }],
2129
+ stopReason: "stop",
2130
+ errorMessage: void 0
2131
+ };
2132
+ return [question, new AssistantMessageComponent(response, true, getMarkdownTheme(), "", 1)];
2133
+ });
2134
+ if (pendingQuestion) {
2135
+ components.push(
2136
+ new UserMessageComponent(escapeTerminalControls2(pendingQuestion), getMarkdownTheme(), 1)
2137
+ );
2138
+ }
2139
+ return components;
2140
+ }
2141
+ function renderTranscriptLines(components, width) {
2142
+ return components.flatMap((component) => component.render(width)).map(stripShellIntegrationMarkers);
2143
+ }
2144
+ function renderSideThreadHeader(width, theme, thinkingLevel) {
2145
+ const thinking = thinkingLevel ? ` \xB7 thinking ${thinkingLevel}` : "";
2146
+ const title = truncateToWidth3(`\u2500 btw \xB7 side thread${thinking} `, width);
2147
+ const ruleWidth = Math.max(0, width - visibleWidth2(title));
2148
+ return theme.fg("muted", `${title}${"\u2500".repeat(ruleWidth)}`);
2149
+ }
2150
+ function thinkingKeyLabel(keybindings) {
2151
+ const key = sanitizeSingleLine(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) || "Shift+Tab";
2152
+ return key.split("+").map((part) => {
2153
+ const lower = part.toLowerCase();
2154
+ if (lower === "shift") return "Shift";
2155
+ if (lower === "ctrl") return "Ctrl";
2156
+ if (lower === "alt") return "Alt";
2157
+ return part.length === 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
2158
+ }).join("+");
2159
+ }
2160
+ function fitComposerLayout(header, contentLines, footer, editorLines, availableRows, statusLines = []) {
2161
+ const lines = [header, ...contentLines, ...statusLines, footer, ...editorLines];
2162
+ if (lines.length <= availableRows) return lines;
2163
+ if (availableRows <= 1) return [header];
2164
+ const editorBudget = Math.max(0, availableRows - 2);
2165
+ return [header, footer, ...fitEditorLines(editorLines, editorBudget)];
2166
+ }
2167
+ function fitEditorLines(editorLines, budget) {
2168
+ if (budget <= 0) return [];
2169
+ if (editorLines.length <= budget) return editorLines;
2170
+ const cursorIndex = editorLines.findIndex((line) => line.includes(CURSOR_MARKER));
2171
+ if (cursorIndex < 0) return editorLines.slice(-budget);
2172
+ const start = Math.min(cursorIndex, editorLines.length - budget);
2173
+ return editorLines.slice(start, start + budget);
2174
+ }
2175
+ function renderSteeringLines(questions, width, theme, maxLines) {
2176
+ if (questions.length === 0 || maxLines <= 0) return [];
2177
+ const formatQuestion = (question) => sanitizeSingleLine(question) || "(non-printing message)";
2178
+ if (maxLines === 1 && questions.length > 1) {
2179
+ return [
2180
+ truncateToWidth3(
2181
+ theme.fg(
2182
+ "dim",
2183
+ `Steering (+${questions.length - 1} more): ${formatQuestion(questions[0] ?? "")}`
2184
+ ),
2185
+ width
2186
+ )
2187
+ ];
2188
+ }
2189
+ const hasOverflow = questions.length > maxLines;
2190
+ const questionLimit = hasOverflow ? Math.max(1, maxLines - 1) : maxLines;
2191
+ const lines = questions.slice(0, questionLimit).map(
2192
+ (question) => truncateToWidth3(theme.fg("dim", `Steering: ${formatQuestion(question)}`), width)
2193
+ );
2194
+ if (hasOverflow) {
2195
+ lines.push(
2196
+ truncateToWidth3(
2197
+ theme.fg("dim", `Steering: \u2026 +${questions.length - questionLimit} more`),
2198
+ width
2199
+ )
2200
+ );
2201
+ }
2202
+ return lines;
2203
+ }
2204
+ function stripShellIntegrationMarkers(line) {
2205
+ return OSC133_MARKERS.reduce((result, marker) => result.replaceAll(marker, ""), line);
2206
+ }
2207
+ function escapeTerminalControls2(text) {
2208
+ return [...text].map((character) => {
2209
+ if (character === "\n") return character;
2210
+ if (character === " ") return " ";
2211
+ const code = character.charCodeAt(0);
2212
+ if (code <= 31 || code >= 127 && code <= 159) {
2213
+ return `\\x${code.toString(16).padStart(2, "0")}`;
2214
+ }
2215
+ return character;
2216
+ }).join("");
2217
+ }
2218
+
2219
+ // src/btw.ts
2220
+ var MAX_CONTEXT_CHARS = 4e4;
2221
+ function createModelRegistryCompleteSimple(modelRegistry) {
2222
+ return async (model, context, options) => {
2223
+ const provider = modelRegistry.getProvider(model.provider);
2224
+ if (!provider) throw new Error(`No provider registered for model provider: ${model.provider}`);
2225
+ return provider.streamSimple(model, context, options).result();
2226
+ };
2227
+ }
2228
+ async function resolveBtwModel({
2229
+ settings,
2230
+ currentModel,
2231
+ modelRegistry,
2232
+ warn
2233
+ }) {
2234
+ const reportWarning = (message) => warn?.(sanitizeSingleLine(message));
2235
+ if (settings.model) {
2236
+ const fallback = currentModel ? `${currentModel.provider}/${currentModel.id}` : "the current model";
2237
+ const reference = parseBtwModelReference(settings.model);
2238
+ if (!reference) {
2239
+ reportWarning(`pi-btw model ${settings.model} is invalid; falling back to ${fallback}.`);
2240
+ return resolveBtwModel({ settings: {}, currentModel, modelRegistry, warn: reportWarning });
2241
+ }
2242
+ const configuredModel = modelRegistry.find(reference.provider, reference.modelId);
2243
+ if (!configuredModel) {
2244
+ reportWarning(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
2245
+ } else {
2246
+ const sameAsCurrent = configuredModel === currentModel || configuredModel.provider === currentModel?.provider && configuredModel.id === currentModel.id;
2247
+ const fallbackAction = sameAsCurrent ? "no distinct current model is available" : `falling back to ${fallback}`;
2248
+ try {
2249
+ const auth = await modelRegistry.getApiKeyAndHeaders(configuredModel);
2250
+ if (auth.ok && hasRequestAuth(auth)) return { model: configuredModel, auth };
2251
+ const reason = auth.ok ? "has no request credentials" : auth.error;
2252
+ reportWarning(
2253
+ `pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`
2254
+ );
2255
+ } catch (error) {
2256
+ reportWarning(
2257
+ `pi-btw model ${settings.model} credentials failed (${formatError5(error)}); ${fallbackAction}.`
2258
+ );
2259
+ }
2260
+ if (sameAsCurrent) return void 0;
2261
+ }
2262
+ }
2263
+ if (!currentModel) return void 0;
2264
+ try {
2265
+ const auth = await modelRegistry.getApiKeyAndHeaders(currentModel);
2266
+ if (auth.ok && hasRequestAuth(auth)) return { model: currentModel, auth };
2267
+ } catch {
2268
+ }
2269
+ return void 0;
2270
+ }
2271
+ function hasRequestAuth(auth) {
2272
+ return Boolean(
2273
+ auth.apiKey || providerHeadersHaveValue(auth.headers) || auth.env && Object.keys(auth.env).length > 0
2274
+ );
2275
+ }
2276
+ function providerHeadersHaveValue(headers) {
2277
+ return headers !== void 0 && Object.values(headers).some((value) => value !== null);
2278
+ }
2279
+ function formatError5(error) {
2280
+ return error instanceof Error ? error.message : String(error);
2281
+ }
2282
+ function notifySafely3(ctx, message, level) {
2283
+ try {
2284
+ ctx.ui.notify(sanitizeSingleLine(message), level);
2285
+ } catch {
2286
+ }
2287
+ }
2288
+ function btw(pi, dependencies = {}) {
2289
+ const showCommandMenu = dependencies.showCommandMenu ?? showCommandMenuForBtw;
2290
+ const pickEntry = dependencies.pickMainEntry ?? pickMainEntry;
2291
+ const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
2292
+ const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
2293
+ const runThread = dependencies.runThread ?? runBtwThread;
2294
+ const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
2295
+ const resumableThreads = /* @__PURE__ */ new Map();
2296
+ let nextThreadNumber = 1;
2297
+ const listResumeThreads = () => [...resumableThreads.values()].reverse().filter((state) => state.thread.turns.length > 0 && state.title).sort(
2298
+ (first, second) => second.updatedAt - first.updatedAt || second.createdAt - first.createdAt
2299
+ ).map((state) => ({
2300
+ id: state.id,
2301
+ title: state.title ?? "Untitled side thread",
2302
+ questionCount: state.thread.turns.length
2303
+ }));
2304
+ pi.registerCommand("btw", {
2305
+ description: "Ask a quick side question without adding it to the main conversation",
2306
+ handler: async (args, ctx) => {
2307
+ const question = args.trim();
2308
+ if (ctx.mode !== "tui") {
2309
+ ctx.ui.notify("/btw requires interactive TUI mode", "error");
2310
+ return;
2311
+ }
2312
+ let menuResult = "start";
2313
+ let selectedConversationContext;
2314
+ if (!question) {
2315
+ while (true) {
2316
+ menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
2317
+ if (menuResult === "closed") return;
2318
+ if (menuResult !== "tree") break;
2319
+ const treeResult = await pickEntry(pi, ctx);
2320
+ if (treeResult.kind === "closed") return;
2321
+ if (treeResult.kind === "back") continue;
2322
+ try {
2323
+ if (!ctx.sessionManager.getEntry(treeResult.entryId)) {
2324
+ notifySafely3(ctx, "The selected main-thread entry is no longer available", "warning");
2325
+ continue;
2326
+ }
2327
+ const branch = ctx.sessionManager.getBranch(treeResult.entryId);
2328
+ if (branch.at(-1)?.id !== treeResult.entryId) {
2329
+ notifySafely3(
2330
+ ctx,
2331
+ "The selected main-thread branch is no longer available",
2332
+ "warning"
2333
+ );
2334
+ continue;
2335
+ }
2336
+ selectedConversationContext = buildConversationContext(branch);
2337
+ menuResult = "start";
2338
+ break;
2339
+ } catch {
2340
+ return;
2341
+ }
2342
+ }
2343
+ }
2344
+ const settings = await loadSettings(ctx);
2345
+ const sameAsMainThinkingLevel = settings.thinkingLevel === void 0;
2346
+ const resolution = await resolveModel(settings, ctx);
2347
+ if (resolution.kind === "cancelled") {
2348
+ notifySafely3(ctx, "Cancelled", "info");
2349
+ return;
2350
+ }
2351
+ if (resolution.kind === "unavailable") {
2352
+ notifySafely3(ctx, "No available model for /btw", "error");
2353
+ return;
2354
+ }
2355
+ let state = typeof menuResult === "object" ? resumableThreads.get(menuResult.threadId) : void 0;
2356
+ if (typeof menuResult === "object" && !state) {
2357
+ notifySafely3(ctx, "The selected /btw side thread is no longer available", "warning");
2358
+ return;
2359
+ }
2360
+ const startingTurnCount = state?.thread.turns.length ?? 0;
2361
+ try {
2362
+ await runFullscreen(ctx, (fullscreenCtx) => {
2363
+ if (!state) {
2364
+ const createdAt = Date.now();
2365
+ state = {
2366
+ id: `btw-${nextThreadNumber}`,
2367
+ thread: createSideThread(
2368
+ selectedConversationContext ?? buildConversationContext(fullscreenCtx.sessionManager.getBranch())
2369
+ ),
2370
+ thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
2371
+ createdAt,
2372
+ updatedAt: createdAt
2373
+ };
2374
+ nextThreadNumber += 1;
2375
+ }
2376
+ return runThread({
2377
+ initialQuestion: question || void 0,
2378
+ selected: resolution.selected,
2379
+ thinkingLevel: state.thinkingLevel,
2380
+ rememberThinkingLevelChanges: !sameAsMainThinkingLevel && effectiveRememberThinkingLevelChanges(settings),
2381
+ state,
2382
+ ctx: fullscreenCtx
2383
+ });
2384
+ });
2385
+ } finally {
2386
+ if (state?.title && state.thread.turns.length > 0) {
2387
+ if (state.thread.turns.length > startingTurnCount) {
2388
+ resumableThreads.delete(state.id);
2389
+ }
2390
+ resumableThreads.set(state.id, state);
2391
+ }
2392
+ }
2393
+ }
2394
+ });
2395
+ }
2396
+ async function showCommandMenuForBtw(pi, ctx, resumeThreads) {
2397
+ const currentModel = ctx.model;
2398
+ const availableModels = ctx.modelRegistry.getAll();
2399
+ const currentThinkingLevel = pi.getThinkingLevel();
2400
+ const loaded = await readBtwSettings();
2401
+ const settings = loaded.kind === "loaded" ? loaded.settings : {};
2402
+ const configured = settings.model ? parseBtwModelReference(settings.model) : void 0;
2403
+ const configuredModel = configured ? availableModels.find(
2404
+ (model2) => model2.provider === configured.provider && model2.id === configured.modelId
2405
+ ) : void 0;
2406
+ const model = configuredModel ?? currentModel;
2407
+ return showBtwCommandMenu(ctx, {
2408
+ currentThinkingLevel,
2409
+ availableThinkingLevels: model ? getSupportedThinkingLevels(model) : BTW_THINKING_LEVELS,
2410
+ resumeThreads
2411
+ });
2412
+ }
2413
+ async function loadSettingsForCommand(ctx) {
2414
+ const settingsResult = await readBtwSettings();
2415
+ if (settingsResult.kind === "loaded") return settingsResult.settings;
2416
+ if (settingsResult.kind === "invalid") {
2417
+ notifySafely3(ctx, `pi-btw settings ignored: ${settingsResult.reason}`, "warning");
2418
+ }
2419
+ return {};
2420
+ }
2421
+ async function resolveBtwModelWithLoader(settings, ctx) {
2422
+ return ctx.ui.custom((tui, theme, _keybindings, done) => {
2423
+ const loader = new BorderedLoader(tui, theme, "Resolving /btw model credentials...");
2424
+ let settled = false;
2425
+ loader.onAbort = () => {
2426
+ if (settled) return;
2427
+ settled = true;
2428
+ done({ kind: "cancelled" });
2429
+ };
2430
+ resolveBtwModel({
2431
+ settings,
2432
+ currentModel: ctx.model,
2433
+ modelRegistry: ctx.modelRegistry,
2434
+ warn: (message) => {
2435
+ if (!settled) notifySafely3(ctx, message, "warning");
2436
+ }
2437
+ }).then((selected) => {
2438
+ if (settled) return;
2439
+ settled = true;
2440
+ done(selected ? { kind: "selected", selected } : { kind: "unavailable" });
2441
+ }).catch(() => {
2442
+ if (settled) return;
2443
+ settled = true;
2444
+ done({ kind: "unavailable" });
2445
+ });
2446
+ return loader;
2447
+ });
2448
+ }
2449
+ async function runBtwThread({
2450
+ initialQuestion,
2451
+ selected,
2452
+ thinkingLevel,
2453
+ rememberThinkingLevelChanges = false,
2454
+ settingsPath,
2455
+ state,
2456
+ ctx,
2457
+ dependencies = {}
2458
+ }) {
2459
+ const ask = dependencies.ask ?? askThreadQuestion;
2460
+ const interact = dependencies.interact ?? showThreadComposer;
2461
+ const chooseBringToMainAction = dependencies.chooseBringToMain ?? chooseBringToMain;
2462
+ const deliverBringToMainDraft = dependencies.deliverBringToMain ?? loadBringToMainDraft;
2463
+ const persistThinkingLevel = dependencies.persistThinkingLevel ?? ((level) => updateBtwSettings({ thinkingLevel: level }, { settingsPath }));
2464
+ const now = dependencies.now ?? Date.now;
2465
+ const thread = state?.thread ?? createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
2466
+ const thinkingLevels = getSupportedThinkingLevels(selected.model);
2467
+ const pendingWrites = /* @__PURE__ */ new Set();
2468
+ const steeringQuestions = [];
2469
+ let activeThinkingLevel = clampThinkingLevel(
2470
+ selected.model,
2471
+ state?.thinkingLevel ?? thinkingLevel
2472
+ );
2473
+ if (state) state.thinkingLevel = activeThinkingLevel;
2474
+ let pendingQuestion = initialQuestion;
2475
+ let composerDraft;
2476
+ const createThinkingControl = () => ({
2477
+ level: activeThinkingLevel,
2478
+ levels: thinkingLevels,
2479
+ onChange: (level) => {
2480
+ if (!thinkingLevels.includes(level)) return;
2481
+ activeThinkingLevel = level;
2482
+ if (state) state.thinkingLevel = level;
2483
+ if (!rememberThinkingLevelChanges) return;
2484
+ let write;
2485
+ write = Promise.resolve().then(() => persistThinkingLevel(level)).then(() => void 0).catch((error) => {
2486
+ notifySafely3(
2487
+ ctx,
2488
+ `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError5(error)}`,
2489
+ "warning"
2490
+ );
2491
+ }).finally(() => pendingWrites.delete(write));
2492
+ pendingWrites.add(write);
2493
+ }
2494
+ });
2495
+ try {
2496
+ while (true) {
2497
+ if (!pendingQuestion) {
2498
+ const action = await interact(
2499
+ thread,
2500
+ thread.turns.length > 0,
2501
+ ctx,
2502
+ composerDraft,
2503
+ createThinkingControl()
2504
+ );
2505
+ if (action.kind === "close") return { kind: "closed" };
2506
+ if (action.kind === "bringToMain") {
2507
+ const choice = await chooseBringToMainAction(thread, ctx);
2508
+ if (choice.kind === "closed") return choice;
2509
+ if (choice.kind === "back") {
2510
+ composerDraft = action.questionDraft;
2511
+ continue;
2512
+ }
2513
+ const delivery = await deliverBringToMainDraft(choice.draft, ctx, choice.summary);
2514
+ if (delivery === "loaded" || delivery === "closed") return { kind: "closed" };
2515
+ composerDraft = action.questionDraft;
2516
+ continue;
2517
+ }
2518
+ composerDraft = void 0;
2519
+ pendingQuestion = action.question;
2520
+ }
2521
+ const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx, {
2522
+ questions: steeringQuestions,
2523
+ submit: (question) => steeringQuestions.push(question),
2524
+ thinking: createThinkingControl()
2525
+ });
2526
+ if (result.kind === "aborted") {
2527
+ notifySafely3(ctx, "Cancelled", "info");
2528
+ return { kind: "closed" };
2529
+ }
2530
+ if (result.kind === "error") {
2531
+ thread.turns.push({
2532
+ kind: "error",
2533
+ question: pendingQuestion,
2534
+ answer: result.message
2535
+ });
2536
+ }
2537
+ if (state) {
2538
+ state.title ||= sanitizeSingleLine(pendingQuestion) || "Untitled side thread";
2539
+ state.updatedAt = now();
2540
+ }
2541
+ pendingQuestion = steeringQuestions.shift();
2542
+ }
2543
+ } finally {
2544
+ await Promise.allSettled([...pendingWrites]);
2545
+ }
2546
+ }
2547
+ async function chooseBringToMain(thread, ctx, dependencies = {}) {
2548
+ const answered = getAnsweredTurns(thread.turns);
2549
+ if (answered.length === 0) return { kind: "back" };
2550
+ const showMenu = dependencies.showMenu ?? showBtwMenu;
2551
+ const showPreview = dependencies.showPreview ?? showBringToMainPreview;
2552
+ const makeChoice = (segments) => ({
2553
+ kind: "bringToMain",
2554
+ draft: formatBtwBringToMain(segments),
2555
+ summary: summarizeBringToMain(segments)
2556
+ });
2557
+ const latestSegments = buildQuickBringToMainSegments(thread.turns, { kind: "latest" });
2558
+ const entireSegments = buildQuickBringToMainSegments(thread.turns, { kind: "entire" });
2559
+ const latestOption = `Latest question and answer 1 Q&A \xB7 ~${estimateBringToMainTokens(latestSegments)} tokens`;
2560
+ const fromOption = "From a question onward\u2026 Choose a starting question";
2561
+ const exactOption = "Select exact text\u2026 Lines or characters";
2562
+ const entireOption = `Entire side thread ${answered.length} Q&A \xB7 ~${estimateBringToMainTokens(entireSegments)} tokens`;
2563
+ const cancelOption = "Cancel Return to the side thread";
2564
+ let selectedScope;
2565
+ while (true) {
2566
+ const scopeResult = await showMenu(
2567
+ ctx,
2568
+ "Bring what back to the main thread?",
2569
+ [latestOption, fromOption, exactOption, entireOption, cancelOption],
2570
+ selectedScope
2571
+ );
2572
+ if (scopeResult.kind === "close") return { kind: "closed" };
2573
+ if (scopeResult.kind === "back" || scopeResult.value === cancelOption) return { kind: "back" };
2574
+ const scope = scopeResult.value;
2575
+ selectedScope = scope;
2576
+ if (scope === latestOption) return makeChoice(latestSegments);
2577
+ if (scope === entireOption) {
2578
+ const choice = makeChoice(entireSegments);
2579
+ const preview = await showPreview(ctx, choice.draft, choice.summary);
2580
+ if (preview.kind === "close") return { kind: "closed" };
2581
+ if (preview.kind === "back") continue;
2582
+ return choice;
2583
+ }
2584
+ if (scope === fromOption) {
2585
+ const questions = answered.map(
2586
+ (turn, index) => `${index + 1}. ${truncatePreview(sanitizeSingleLine(turn.question))}`
2587
+ );
2588
+ let selectedQuestion;
2589
+ while (true) {
2590
+ const questionResult = await showMenu(
2591
+ ctx,
2592
+ "Start from which question?",
2593
+ questions,
2594
+ selectedQuestion
2595
+ );
2596
+ if (questionResult.kind === "close") return { kind: "closed" };
2597
+ if (questionResult.kind === "back") break;
2598
+ const answeredTurnIndex = questions.indexOf(questionResult.value);
2599
+ if (answeredTurnIndex < 0) continue;
2600
+ selectedQuestion = questionResult.value;
2601
+ const choice = makeChoice(
2602
+ buildQuickBringToMainSegments(thread.turns, { kind: "from", answeredTurnIndex })
2603
+ );
2604
+ const preview = await showPreview(ctx, choice.draft, choice.summary);
2605
+ if (preview.kind === "close") return { kind: "closed" };
2606
+ if (preview.kind === "back") continue;
2607
+ return choice;
2608
+ }
2609
+ continue;
2610
+ }
2611
+ if (scope !== exactOption) continue;
2612
+ let selectionState;
2613
+ while (true) {
2614
+ const selectedRange = await showBtwCustomPreservingEditor(
2615
+ ctx,
2616
+ (tui, theme, keybindings, done) => {
2617
+ let selector;
2618
+ selector = new BtwTextRangeSelector(
2619
+ tui,
2620
+ theme,
2621
+ keybindings,
2622
+ thread.turns,
2623
+ (action) => {
2624
+ if (action.kind === "back") done({ kind: "back" });
2625
+ else if (action.kind === "close") done({ kind: "closed" });
2626
+ else done({ ...makeChoice(action.segments), selectionState: selector.getState() });
2627
+ },
2628
+ selectionState
2629
+ );
2630
+ return selector;
2631
+ }
2632
+ );
2633
+ if (!selectedRange) return { kind: "closed" };
2634
+ if (selectedRange.kind === "closed") return selectedRange;
2635
+ if (selectedRange.kind === "back") break;
2636
+ const preview = await showPreview(ctx, selectedRange.draft, selectedRange.summary);
2637
+ if (preview.kind === "close") return { kind: "closed" };
2638
+ if (preview.kind === "back") {
2639
+ selectionState = selectedRange.selectionState;
2640
+ continue;
2641
+ }
2642
+ return {
2643
+ kind: "bringToMain",
2644
+ draft: selectedRange.draft,
2645
+ summary: selectedRange.summary
2646
+ };
2647
+ }
2648
+ }
2649
+ }
2650
+ async function showBringToMainPreview(ctx, draft, summary) {
2651
+ const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
2652
+ if (ctx.signal?.aborted) return { kind: "close" };
2653
+ let confirmed = false;
2654
+ const count = summary.messages === 1 ? "1 message" : `${summary.messages} messages`;
2655
+ const lineCount = summary.lines === 1 ? "1 line" : `${summary.lines} lines`;
2656
+ const menu = defineMenu({
2657
+ start: "preview",
2658
+ screens: {
2659
+ preview: () => ({
2660
+ kind: "review",
2661
+ title: `Preview \xB7 ${count} \xB7 ${lineCount} \xB7 ~${summary.tokens} tokens`,
2662
+ content: draft,
2663
+ viewportSize: "adaptive",
2664
+ hint: "back",
2665
+ confirm: { id: "bring", label: "Bring", action: "bring" }
2666
+ })
2667
+ },
2668
+ actions: {
2669
+ bring: async () => {
2670
+ confirmed = true;
2671
+ return { kind: "close" };
2672
+ }
2673
+ }
2674
+ });
2675
+ const result = await runBtwMenuPreservingEditor(
2676
+ ctx,
2677
+ (menuContext) => runMenu(menuContext, menu, { getState: () => void 0 })
2678
+ );
2679
+ if (confirmed && result.kind === "closed" && result.reason === "close") {
2680
+ return { kind: "bring" };
2681
+ }
2682
+ return terminalBtwMenuAction(result);
2683
+ }
2684
+ async function showBtwMenu(ctx, title, options, initialValue) {
2685
+ const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
2686
+ if (ctx.signal?.aborted) return { kind: "close" };
2687
+ const items = options.map((label, index) => ({ id: `option-${index}`, label }));
2688
+ const initialIndex = initialValue === void 0 ? -1 : options.indexOf(initialValue);
2689
+ let selectedValue;
2690
+ const menu = defineMenu({
2691
+ start: "choices",
2692
+ screens: {
2693
+ choices: () => ({
2694
+ kind: "choice",
2695
+ title,
2696
+ items,
2697
+ action: "select",
2698
+ initialItemId: initialIndex >= 0 ? `option-${initialIndex}` : void 0,
2699
+ hint: "back"
2700
+ })
2701
+ },
2702
+ actions: {
2703
+ select: async ({ itemId }) => {
2704
+ const index = Number.parseInt(itemId.slice("option-".length), 10);
2705
+ selectedValue = options[index];
2706
+ return selectedValue === void 0 ? { kind: "stay" } : { kind: "close" };
2707
+ }
2708
+ }
2709
+ });
2710
+ const result = await runBtwMenuPreservingEditor(
2711
+ ctx,
2712
+ (menuContext) => runMenu(menuContext, menu, { getState: () => void 0 })
2713
+ );
2714
+ return selectedValue !== void 0 && result.kind === "closed" && result.reason === "close" ? { kind: "select", value: selectedValue } : terminalBtwMenuAction(result);
2715
+ }
2716
+ function terminalBtwMenuAction(result) {
2717
+ if (result.kind === "closed") return { kind: result.reason };
2718
+ if (result.kind === "error") throw result.error;
2719
+ return { kind: "close" };
2720
+ }
2721
+ async function loadBringToMainDraft(draft, ctx, summary) {
2722
+ const describeContent = () => `${summary.messages} ${summary.messages === 1 ? "message" : "messages"} (~${summary.tokens} ${summary.tokens === 1 ? "token" : "tokens"})`;
2723
+ const existing = ctx.ui.getEditorText();
2724
+ if (!existing.trim()) {
2725
+ ctx.ui.setEditorText(draft);
2726
+ ctx.ui.notify(
2727
+ `Brought ${describeContent()} to the main editor. Review and submit when ready.`,
2728
+ "info"
2729
+ );
2730
+ return "loaded";
2731
+ }
2732
+ const appendOption = "Append after current draft Recommended";
2733
+ const replaceOption = "\u26A0 Replace current draft Discards current editor text";
2734
+ const cancelOption = "Cancel Return to the side thread";
2735
+ while (true) {
2736
+ const action = await showBtwMenu(ctx, "The main editor already has a draft", [
2737
+ appendOption,
2738
+ replaceOption,
2739
+ cancelOption
2740
+ ]);
2741
+ if (action.kind === "close") return "closed";
2742
+ if (action.kind === "back" || action.value === cancelOption) return "back";
2743
+ if (action.value === appendOption) {
2744
+ ctx.ui.setEditorText(`${ctx.ui.getEditorText()}
2745
+
2746
+ ${draft}`);
2747
+ ctx.ui.notify(
2748
+ `Appended ${describeContent()} to the existing main-editor draft. Review and submit when ready.`,
2749
+ "info"
2750
+ );
2751
+ return "loaded";
2752
+ }
2753
+ if (action.value !== replaceOption) continue;
2754
+ const current = ctx.ui.getEditorText();
2755
+ const characters = [...current].length;
2756
+ const confirmed = await showBtwMenu(
2757
+ ctx,
2758
+ `Replace the current ${characters}-character editor draft?`,
2759
+ ["Back Keep current editor text", "\u26A0 Replace current draft Cannot be undone"]
2760
+ );
2761
+ if (confirmed.kind === "close") return "closed";
2762
+ if (confirmed.kind === "back" || confirmed.value === "Back Keep current editor text") continue;
2763
+ if (confirmed.value !== "\u26A0 Replace current draft Cannot be undone") continue;
2764
+ if (ctx.ui.getEditorText() !== current) {
2765
+ ctx.ui.notify(
2766
+ "The main editor changed during confirmation. Review the updated draft and choose again.",
2767
+ "warning"
2768
+ );
2769
+ continue;
2770
+ }
2771
+ ctx.ui.setEditorText(draft);
2772
+ ctx.ui.notify(
2773
+ `Replaced the main-editor draft with ${describeContent()}. Review and submit when ready.`,
2774
+ "info"
2775
+ );
2776
+ return "loaded";
2777
+ }
2778
+ }
2779
+ function truncatePreview(text) {
2780
+ return text.length <= 72 ? text : `${text.slice(0, 69)}\u2026`;
2781
+ }
2782
+ async function askThreadQuestion(thread, question, selected, thinkingLevel, ctx, steering) {
2783
+ return ctx.ui.custom(
2784
+ (tui, theme, keybindings, done) => {
2785
+ let settled = false;
2786
+ const view = new BtwAnsweringView(
2787
+ tui,
2788
+ theme,
2789
+ thread.turns,
2790
+ question,
2791
+ () => {
2792
+ if (settled) return;
2793
+ settled = true;
2794
+ done({ kind: "aborted" });
2795
+ },
2796
+ thinkingLevel,
2797
+ {
2798
+ steering: {
2799
+ questions: steering.questions,
2800
+ onSubmit: steering.submit,
2801
+ thinking: { ...steering.thinking, keybindings }
2802
+ }
2803
+ }
2804
+ );
2805
+ completeSideThreadTurn({
2806
+ thread,
2807
+ question,
2808
+ model: selected.model,
2809
+ thinkingLevel,
2810
+ auth: selected.auth,
2811
+ signal: view.signal,
2812
+ completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry)
2813
+ }).then((result) => {
2814
+ if (settled) return;
2815
+ settled = true;
2816
+ view.finish();
2817
+ done(result);
2818
+ });
2819
+ return view;
2820
+ }
2821
+ );
2822
+ }
2823
+ async function showThreadComposer(thread, startAtBottom, ctx, initialQuestion, thinking) {
2824
+ return ctx.ui.custom(
2825
+ (tui, theme, keybindings, done) => new BtwTranscriptPager(tui, theme, thread.turns, done, {
2826
+ startAtBottom,
2827
+ initialQuestion,
2828
+ thinking: { ...thinking, keybindings }
2829
+ })
2830
+ );
2831
+ }
2832
+ function buildConversationContext(entries) {
2833
+ const sections = [];
2834
+ for (const entry of entries) {
2835
+ if (entry.type !== "message" || !entry.message?.role) continue;
2836
+ const role = entry.message.role;
2837
+ if (role !== "user" && role !== "assistant") continue;
2838
+ const contentLines = extractContentLines(entry.message.content);
2839
+ if (contentLines.length === 0) continue;
2840
+ const label = role === "user" ? "User" : "Assistant";
2841
+ const status = entry.message.stopReason && entry.message.stopReason !== "stop" ? ` (${entry.message.stopReason})` : "";
2842
+ sections.push(`${label}${status}: ${contentLines.join("\n")}`);
2843
+ }
2844
+ return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
2845
+ }
2846
+ function extractContentLines(content) {
2847
+ if (typeof content === "string") return [content.trim()].filter(Boolean);
2848
+ if (!Array.isArray(content)) return [];
2849
+ const lines = [];
2850
+ for (const part of content) {
2851
+ if (!part || typeof part !== "object") continue;
2852
+ const block = part;
2853
+ if (block.type === "text" && typeof block.text === "string") {
2854
+ lines.push(block.text.trim());
2855
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
2856
+ lines.push(`Tool call: ${block.name}(${formatJson(block.arguments)})`);
2857
+ } else if (block.type === "toolResult" && typeof block.name === "string") {
2858
+ lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
2859
+ }
2860
+ }
2861
+ return lines.filter(Boolean);
2862
+ }
2863
+ function formatJson(value) {
2864
+ if (value === void 0) return "";
2865
+ try {
2866
+ return JSON.stringify(value);
2867
+ } catch {
2868
+ return String(value);
2869
+ }
2870
+ }
2871
+ function truncateFromStart(text, maxChars) {
2872
+ if (text.length <= maxChars) return text;
2873
+ return `[Earlier context omitted; showing the last ${maxChars} characters.]
2874
+ ${text.slice(-maxChars)}`;
2875
+ }
2876
+ export {
2877
+ btw as default
2878
+ };
2879
+ //# sourceMappingURL=index.ts.map