pi-btw-cc 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/index.ts +4 -3
- package/src/overlay.ts +172 -30
package/README.md
CHANGED
|
@@ -32,12 +32,15 @@ Needs Node >= 22.19 and pi 0.85.x.
|
|
|
32
32
|
| --- | --- |
|
|
33
33
|
| `↑` `↓` | Scroll the answer |
|
|
34
34
|
| `⇧←` `⇧→` | Browse the side thread |
|
|
35
|
+
| `e` | Expand or fold a long side question |
|
|
35
36
|
| `c` | Copy the selected answer |
|
|
36
37
|
| `f` | Promote the selected exchange into the main conversation |
|
|
37
38
|
| `x` | Delete the selected side question |
|
|
38
39
|
| `Esc` `Enter` | Close |
|
|
39
40
|
|
|
40
|
-
The selected exchange is the one expanded in the body and the one `⇧←/⇧→`, `c`, `f` and `x` act on. The thread list under it marks the selection with `❯`, and the footer shows the position as `2/3`.
|
|
41
|
+
The selected exchange is the one expanded in the body and the one `⇧←/⇧→`, `e`, `c`, `f` and `x` act on. The thread list under it marks the selection with `❯`, and the footer shows the position as `2/3`.
|
|
42
|
+
|
|
43
|
+
A question that wraps to more than three rows is folded to its first line plus a `… N more lines (e to show)` marker, so it does not push the answer — or the spinner while the request runs — out of the body. `e` unfolds the selected question; opening the overlay again starts folded.
|
|
41
44
|
|
|
42
45
|
<img src="docs/btw-browse.png" alt="Browsing to an older side question: the thread list marks the selection and the footer shows 1/3" width="880">
|
|
43
46
|
|
|
@@ -52,6 +55,7 @@ The selected exchange is the one expanded in the body and the one `⇧←/⇧→
|
|
|
52
55
|
- Promotion only appends, so the main conversation's cached prompt prefix stays valid. While a side request is still running, `f` waits instead of aborting it.
|
|
53
56
|
- Side requests don't share the main conversation's prompt cache, so their input tokens are billed in full.
|
|
54
57
|
- On narrow terminals the key hints wrap onto more rows and the answer area shrinks to keep them visible.
|
|
58
|
+
- Depending on the fold, a state change reveals what changed: a folded question keeps the top of the exchange in view, an unfolded one scrolls to the answer (or to the error) below the question. On a terminal too short to show both, the answer or status row wins over the question top.
|
|
55
59
|
|
|
56
60
|
## Development
|
|
57
61
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-btw-cc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Claude Code-style /btw side questions for pi: a no-tools side thread in a floating overlay, persisted per session and promotable into the main conversation with one key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* replayed into the next request. The thread is persisted in the session file
|
|
10
10
|
* as `btw-thread` custom entries, so it survives reloads, restarts and
|
|
11
11
|
* `/resume` of the same session, and follows the active `/tree` branch.
|
|
12
|
-
* - Overlay keys: ↑/↓ scroll, ⇧←/→ browse the side thread, `
|
|
13
|
-
*
|
|
14
|
-
* delete the selected exchange,
|
|
12
|
+
* - Overlay keys: ↑/↓ scroll, ⇧←/→ browse the side thread, `e` expand or fold a
|
|
13
|
+
* long question, `c` copy the selected answer, `f` promote the selected
|
|
14
|
+
* exchange into the main conversation, `x` delete the selected exchange,
|
|
15
|
+
* Esc/Enter close.
|
|
15
16
|
* - `/btw` without a question reopens the overlay on the existing thread.
|
|
16
17
|
*
|
|
17
18
|
* Nothing reaches the main conversation unless `f` is pressed.
|
package/src/overlay.ts
CHANGED
|
@@ -15,15 +15,20 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Only the body scrolls; the title bar and footer stay in place. `⇧←/→` moves
|
|
17
17
|
* the selection: the selected exchange is the one expanded in the body and the
|
|
18
|
-
* one `c`, `f` and `x` act on, and the dimmed window below the separator
|
|
19
|
-
* the exchanges around it while marking the selected one, mirroring Claude
|
|
18
|
+
* one `e`, `c`, `f` and `x` act on, and the dimmed window below the separator
|
|
19
|
+
* lists the exchanges around it while marking the selected one, mirroring Claude
|
|
20
20
|
* Code's overlay.
|
|
21
|
+
*
|
|
22
|
+
* A question that wraps to more than `QUESTION_COLLAPSE_LINES` rows is folded to
|
|
23
|
+
* its first line plus a `… N more lines (e to show)` marker, and `e` unfolds it.
|
|
24
|
+
* Without the fold a long question fills the whole body and pushes the answer or
|
|
25
|
+
* the pending spinner out of view; with it a state change only ever reveals the
|
|
26
|
+
* answer, the spinner or the error it belongs to.
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
import { randomUUID } from "node:crypto";
|
|
24
30
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
25
31
|
import {
|
|
26
|
-
Container,
|
|
27
32
|
Key,
|
|
28
33
|
Markdown,
|
|
29
34
|
matchesKey,
|
|
@@ -31,6 +36,7 @@ import {
|
|
|
31
36
|
Text,
|
|
32
37
|
truncateToWidth,
|
|
33
38
|
visibleWidth,
|
|
39
|
+
wrapTextWithAnsi,
|
|
34
40
|
type Component,
|
|
35
41
|
type TUI,
|
|
36
42
|
type TuiMouseEvent,
|
|
@@ -115,6 +121,13 @@ export const OVERLAY_MIN_WIDTH = 60;
|
|
|
115
121
|
/** Minimum and maximum body height in rows. */
|
|
116
122
|
const MIN_BODY_ROWS = 4;
|
|
117
123
|
const MAX_BODY_ROWS = 30;
|
|
124
|
+
/**
|
|
125
|
+
* Wrapped question rows a question may take before it is folded.
|
|
126
|
+
*
|
|
127
|
+
* Three rows fit next to the answer on every supported terminal; a longer
|
|
128
|
+
* question would leave the body with no room for what the reader came for.
|
|
129
|
+
*/
|
|
130
|
+
const QUESTION_COLLAPSE_LINES = 3;
|
|
118
131
|
|
|
119
132
|
export class BtwOverlay implements Component {
|
|
120
133
|
private readonly tui: TUI;
|
|
@@ -126,6 +139,22 @@ export class BtwOverlay implements Component {
|
|
|
126
139
|
private readonly done: (result: BtwOverlayResult) => void;
|
|
127
140
|
|
|
128
141
|
private thread: BtwExchange[];
|
|
142
|
+
/** Ids of exchanges whose long question is unfolded; every exchange starts folded. */
|
|
143
|
+
private readonly expandedIds = new Set<string>();
|
|
144
|
+
/** Unfold state of the in-flight question, carried over to the exchange it becomes. */
|
|
145
|
+
private pendingExpanded = false;
|
|
146
|
+
/**
|
|
147
|
+
* Anchor the next paint scrolls to after a state change.
|
|
148
|
+
*
|
|
149
|
+
* `questionTop` is the explicit unfold request, `status` and `answer` are the
|
|
150
|
+
* two blocks a finished request produces; null means the reader owns the scroll
|
|
151
|
+
* position and no reveal may move it.
|
|
152
|
+
*/
|
|
153
|
+
private reveal: "questionTop" | "status" | "answer" | null = null;
|
|
154
|
+
/** First text row of the selected answer inside `contentLines`; -1 when absent. */
|
|
155
|
+
private answerStart = -1;
|
|
156
|
+
/** First text row of the pending spinner or error inside `contentLines`; -1 when absent. */
|
|
157
|
+
private statusStart = -1;
|
|
129
158
|
/** Index into `thread` of the exchange expanded in the body; always clamped. */
|
|
130
159
|
private selectedIndex = 0;
|
|
131
160
|
/** Footer rows the last render needed; the body shrinks when the footer wraps. */
|
|
@@ -201,6 +230,10 @@ export class BtwOverlay implements Component {
|
|
|
201
230
|
this.close();
|
|
202
231
|
return;
|
|
203
232
|
}
|
|
233
|
+
if (data === "e") {
|
|
234
|
+
this.toggleExpanded();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
204
237
|
if (data === "c") {
|
|
205
238
|
void this.copySelectedAnswer();
|
|
206
239
|
return;
|
|
@@ -224,7 +257,10 @@ export class BtwOverlay implements Component {
|
|
|
224
257
|
render(width: number): string[] {
|
|
225
258
|
const innerWidth = Math.max(24, width - 4);
|
|
226
259
|
if (this.dirty || this.cachedWidth !== innerWidth) {
|
|
227
|
-
|
|
260
|
+
const content = this.buildContent(innerWidth);
|
|
261
|
+
this.contentLines = content.lines;
|
|
262
|
+
this.answerStart = content.answerStart;
|
|
263
|
+
this.statusStart = content.statusStart;
|
|
228
264
|
this.cachedWidth = innerWidth;
|
|
229
265
|
this.dirty = false;
|
|
230
266
|
}
|
|
@@ -233,6 +269,12 @@ export class BtwOverlay implements Component {
|
|
|
233
269
|
this.footerRows = footer.length;
|
|
234
270
|
const bodyHeight = this.bodyHeight();
|
|
235
271
|
const maxScroll = Math.max(0, this.contentLines.length - bodyHeight);
|
|
272
|
+
// The reveal runs after the content is measured, because both the target row
|
|
273
|
+
// and the body height come from the freshly built lines.
|
|
274
|
+
if (this.reveal !== null) {
|
|
275
|
+
this.scrollTop = Math.min(Math.max(0, this.revealTarget(bodyHeight)), maxScroll);
|
|
276
|
+
this.reveal = null;
|
|
277
|
+
}
|
|
236
278
|
if (this.scrollTop > maxScroll) this.scrollTop = maxScroll;
|
|
237
279
|
const visible = this.contentLines.slice(this.scrollTop, this.scrollTop + bodyHeight);
|
|
238
280
|
|
|
@@ -264,6 +306,9 @@ export class BtwOverlay implements Component {
|
|
|
264
306
|
|
|
265
307
|
private startAsk(question: string, ask: (signal: AbortSignal) => Promise<BtwQueryOutcome>): void {
|
|
266
308
|
this.pending = { question };
|
|
309
|
+
// A fresh question always starts folded, so the spinner under it is visible.
|
|
310
|
+
this.pendingExpanded = false;
|
|
311
|
+
this.reveal = "status";
|
|
267
312
|
this.abort = new AbortController();
|
|
268
313
|
this.spinnerTimer = setInterval(() => {
|
|
269
314
|
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
@@ -289,21 +334,28 @@ export class BtwOverlay implements Component {
|
|
|
289
334
|
replayed: this.replayedCount,
|
|
290
335
|
};
|
|
291
336
|
this.thread = appendExchange(this.thread, exchange);
|
|
337
|
+
// The fold state the reader chose while waiting carries over to the
|
|
338
|
+
// exchange the question becomes.
|
|
339
|
+
if (this.pendingExpanded) this.expandedIds.add(exchange.id);
|
|
340
|
+
this.pendingExpanded = false;
|
|
292
341
|
this.pending = null;
|
|
293
342
|
// A finished answer is what the reader wants to see next, so browsing
|
|
294
|
-
// ends here and the body
|
|
295
|
-
//
|
|
296
|
-
//
|
|
343
|
+
// ends here and the body reveals the newest exchange: a folded question
|
|
344
|
+
// keeps its exchange top, an unfolded one scrolls to the answer. The
|
|
345
|
+
// exchange appearing in the body is the whole signal; no footer flash is
|
|
346
|
+
// needed for it.
|
|
297
347
|
this.selectNewest();
|
|
298
348
|
this.sink.add(exchange);
|
|
299
|
-
this.
|
|
349
|
+
this.reveal = "answer";
|
|
300
350
|
break;
|
|
301
351
|
}
|
|
302
352
|
case "error":
|
|
303
353
|
this.pending = { question, error: outcome.message };
|
|
354
|
+
this.reveal = "status";
|
|
304
355
|
break;
|
|
305
356
|
case "cancelled":
|
|
306
357
|
this.pending = { question, error: "The side request was cancelled." };
|
|
358
|
+
this.reveal = "status";
|
|
307
359
|
break;
|
|
308
360
|
}
|
|
309
361
|
this.dirty = true;
|
|
@@ -361,11 +413,12 @@ export class BtwOverlay implements Component {
|
|
|
361
413
|
}
|
|
362
414
|
this.thread = removeExchange(this.thread, selected.id);
|
|
363
415
|
this.sink.remove(selected.id);
|
|
416
|
+
this.expandedIds.delete(selected.id);
|
|
364
417
|
// Deleting moves the reader back to the newest remaining exchange, so a
|
|
365
418
|
// deletion never leaves the body pointing at an evicted index.
|
|
366
419
|
this.selectNewest();
|
|
367
420
|
this.dirty = true;
|
|
368
|
-
this.
|
|
421
|
+
this.reveal = "answer";
|
|
369
422
|
this.flash("Deleted this side question");
|
|
370
423
|
}
|
|
371
424
|
|
|
@@ -385,12 +438,68 @@ export class BtwOverlay implements Component {
|
|
|
385
438
|
if (next === this.selectedIndex) return;
|
|
386
439
|
this.selectedIndex = next;
|
|
387
440
|
this.dirty = true;
|
|
388
|
-
// The
|
|
389
|
-
//
|
|
390
|
-
|
|
441
|
+
// The selected exchange builds the body from its first row, so browsing
|
|
442
|
+
// lands on the answer of a long unfolded question and on the whole folded
|
|
443
|
+
// block otherwise.
|
|
444
|
+
this.reveal = "answer";
|
|
391
445
|
this.tui.requestRender();
|
|
392
446
|
}
|
|
393
447
|
|
|
448
|
+
/** `e` folds or unfolds the question the reader is looking at. */
|
|
449
|
+
private toggleExpanded(): void {
|
|
450
|
+
const question = this.pending?.question ?? this.selectedExchange()?.question;
|
|
451
|
+
if (question === undefined || !this.isCollapsible(question)) return;
|
|
452
|
+
if (this.pending !== null) {
|
|
453
|
+
this.pendingExpanded = !this.pendingExpanded;
|
|
454
|
+
} else {
|
|
455
|
+
const id = this.selectedExchange()?.id;
|
|
456
|
+
if (id === undefined) return;
|
|
457
|
+
if (this.expandedIds.has(id)) this.expandedIds.delete(id);
|
|
458
|
+
else this.expandedIds.add(id);
|
|
459
|
+
}
|
|
460
|
+
this.dirty = true;
|
|
461
|
+
// Unfolding is a request to read the question from its first line; folding
|
|
462
|
+
// gives the body back to the answer or the spinner below it.
|
|
463
|
+
const expanded = this.pending !== null ? this.pendingExpanded : this.isSelectedExpanded();
|
|
464
|
+
this.reveal = expanded ? "questionTop" : this.pending !== null ? "status" : "answer";
|
|
465
|
+
this.tui.requestRender();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Whether the selected exchange has a long question unfolded right now. */
|
|
469
|
+
private isSelectedExpanded(): boolean {
|
|
470
|
+
const selected = this.selectedExchange();
|
|
471
|
+
if (selected === undefined) return false;
|
|
472
|
+
return this.expandedIds.has(selected.id) && this.isCollapsible(selected.question);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Whether the question needs more rows than `QUESTION_COLLAPSE_LINES` at the last rendered width.
|
|
477
|
+
*
|
|
478
|
+
* `cachedWidth` is only set by a paint. Before the first one no width is known, and measuring at
|
|
479
|
+
* -1 would report every question as folded, so a fold key arriving then has to be ignored
|
|
480
|
+
* instead of recording a decision the question does not deserve.
|
|
481
|
+
*/
|
|
482
|
+
private isCollapsible(question: string): boolean {
|
|
483
|
+
if (this.cachedWidth <= 0) return false;
|
|
484
|
+
return wrapTextWithAnsi(this.questionLine(question), this.cachedWidth).length > QUESTION_COLLAPSE_LINES;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Body row the next state change scrolls to.
|
|
489
|
+
*
|
|
490
|
+
* An unfolded question is taller than the body, so the answer (or the pending
|
|
491
|
+
* status line) is pinned to the top and the question scrolls away above it. A
|
|
492
|
+
* folded block is short enough to keep its exchange top, and the viewport only
|
|
493
|
+
* moves as far as a very short body needs to keep the target row visible.
|
|
494
|
+
*/
|
|
495
|
+
private revealTarget(bodyHeight: number): number {
|
|
496
|
+
if (this.reveal === "questionTop") return 0;
|
|
497
|
+
const expanded = this.pending !== null ? this.pendingExpanded : this.isSelectedExpanded();
|
|
498
|
+
const target = this.reveal === "status" ? this.statusStart : this.answerStart;
|
|
499
|
+
if (expanded) return target;
|
|
500
|
+
return target < bodyHeight ? 0 : target - bodyHeight + 1;
|
|
501
|
+
}
|
|
502
|
+
|
|
394
503
|
private flash(text: string, tone: "success" | "warning" | "error" = "success"): void {
|
|
395
504
|
this.flashState = { text, tone };
|
|
396
505
|
if (this.flashTimer !== null) clearTimeout(this.flashTimer);
|
|
@@ -448,16 +557,31 @@ export class BtwOverlay implements Component {
|
|
|
448
557
|
return Math.max(1, Math.min(Math.floor((this.tui.terminal.rows * OVERLAY_MAX_HEIGHT_PERCENT) / 100), available));
|
|
449
558
|
}
|
|
450
559
|
|
|
451
|
-
private buildContent(innerWidth: number): string[] {
|
|
452
|
-
const
|
|
560
|
+
private buildContent(innerWidth: number): { lines: string[]; answerStart: number; statusStart: number } {
|
|
561
|
+
const lines: string[] = [];
|
|
562
|
+
let answerStart = -1;
|
|
563
|
+
let statusStart = -1;
|
|
564
|
+
/**
|
|
565
|
+
* Append one child and return the row its first text line lands on.
|
|
566
|
+
*
|
|
567
|
+
* `paddingTop` is the child's vertical padding: a blank row above the text
|
|
568
|
+
* means the reader would see that empty row first if a reveal pinned the
|
|
569
|
+
* child itself, so the mark has to skip it.
|
|
570
|
+
*/
|
|
571
|
+
const push = (component: Component, paddingTop = 0): number => {
|
|
572
|
+
const start = lines.length + paddingTop;
|
|
573
|
+
for (const line of component.render(innerWidth)) lines.push(line);
|
|
574
|
+
return start;
|
|
575
|
+
};
|
|
453
576
|
const pending = this.pending;
|
|
454
577
|
const selected = this.selectedExchange();
|
|
455
578
|
|
|
456
579
|
if (pending !== null) {
|
|
457
580
|
// The in-flight question takes the expanded slot: every finished
|
|
458
|
-
// exchange moves into the dimmed window below it.
|
|
459
|
-
|
|
460
|
-
|
|
581
|
+
// exchange moves into the dimmed window below it. Unlike the finished
|
|
582
|
+
// exchange it has no blank row above it, so the spinner stays close.
|
|
583
|
+
push(new Text(this.questionBlockText(pending.question, this.pendingExpanded, innerWidth), 0, 0));
|
|
584
|
+
statusStart = push(
|
|
461
585
|
new Text(
|
|
462
586
|
pending.error !== undefined
|
|
463
587
|
? this.theme.fg("error", pending.error)
|
|
@@ -465,26 +589,25 @@ export class BtwOverlay implements Component {
|
|
|
465
589
|
0,
|
|
466
590
|
1,
|
|
467
591
|
),
|
|
592
|
+
1,
|
|
468
593
|
);
|
|
469
594
|
} else if (selected === undefined) {
|
|
470
|
-
|
|
471
|
-
new Text(this.theme.fg("dim", "No side questions in this session yet. Run /btw <question> to ask one."), 0, 0),
|
|
472
|
-
);
|
|
595
|
+
push(new Text(this.theme.fg("dim", "No side questions in this session yet. Run /btw <question> to ask one."), 0, 0));
|
|
473
596
|
} else {
|
|
474
597
|
const count = this.thread.length;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
598
|
+
push(new Text(this.theme.fg("dim", `${count} side question${count === 1 ? "" : "s"}`), 0, 0));
|
|
599
|
+
push(new Text(this.questionBlockText(selected.question, this.isSelectedExpanded(), innerWidth), 0, 1));
|
|
600
|
+
answerStart = push(new Markdown(selected.answer, 0, 1, getMarkdownTheme()), 1);
|
|
478
601
|
}
|
|
479
602
|
|
|
480
603
|
const listed = this.windowEntries();
|
|
481
604
|
if (listed.length > 0) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
605
|
+
push(new Spacer(1));
|
|
606
|
+
push(new Text(this.theme.fg("borderMuted", "─".repeat(innerWidth)), 0, 0));
|
|
607
|
+
push(new Spacer(1));
|
|
485
608
|
const { start, end } = this.windowRange();
|
|
486
609
|
if (start > 0) {
|
|
487
|
-
|
|
610
|
+
push(new Text(this.theme.fg("dim", `… ${start} earlier`), 0, 0));
|
|
488
611
|
}
|
|
489
612
|
for (const entry of listed) {
|
|
490
613
|
// The selected exchange is expanded above and listed here as well: the
|
|
@@ -492,14 +615,14 @@ export class BtwOverlay implements Component {
|
|
|
492
615
|
// reader currently is even while the expanded block is scrolled away.
|
|
493
616
|
const marker = entry.selected ? this.theme.fg("accent", "❯") : this.theme.fg("muted", "·");
|
|
494
617
|
const line = truncateToWidth(`${marker} ${this.theme.fg("muted", entry.exchange.question)}`, innerWidth, "…");
|
|
495
|
-
|
|
618
|
+
push(new Text(line, 0, 0));
|
|
496
619
|
}
|
|
497
620
|
if (end < this.thread.length) {
|
|
498
|
-
|
|
621
|
+
push(new Text(this.theme.fg("dim", `… ${this.thread.length - end} newer`), 0, 0));
|
|
499
622
|
}
|
|
500
623
|
}
|
|
501
624
|
|
|
502
|
-
return
|
|
625
|
+
return { lines, answerStart, statusStart };
|
|
503
626
|
}
|
|
504
627
|
|
|
505
628
|
/**
|
|
@@ -531,6 +654,25 @@ export class BtwOverlay implements Component {
|
|
|
531
654
|
return this.theme.fg("accent", "❯ ") + this.theme.fg("text", question);
|
|
532
655
|
}
|
|
533
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Rendered question block: the full question, or its first line plus a marker.
|
|
659
|
+
*
|
|
660
|
+
* A question that wraps to more than `QUESTION_COLLAPSE_LINES` rows is folded,
|
|
661
|
+
* because it would otherwise push the answer or the spinner out of the body.
|
|
662
|
+
* The marker states how many rows are hidden so nothing disappears silently,
|
|
663
|
+
* and carries the `e` hint: a footer hint for it would push the key hints onto
|
|
664
|
+
* an extra row, which costs the body its own row on every render.
|
|
665
|
+
*/
|
|
666
|
+
private questionBlockText(question: string, expanded: boolean, innerWidth: number): string {
|
|
667
|
+
const rendered = wrapTextWithAnsi(this.questionLine(question), innerWidth);
|
|
668
|
+
if (rendered.length <= QUESTION_COLLAPSE_LINES) return rendered.join("\n");
|
|
669
|
+
if (expanded) return [...rendered, this.theme.fg("dim", " (e to hide)")].join("\n");
|
|
670
|
+
const hidden = rendered.length - 1;
|
|
671
|
+
// Cut the first line one column short so the ellipsis fits inside the frame.
|
|
672
|
+
const first = truncateToWidth(rendered[0] ?? "", innerWidth - 1, "") + "…";
|
|
673
|
+
return `${first}\n${this.theme.fg("dim", ` … ${hidden} more line${hidden === 1 ? "" : "s"} (e to show)`)}`;
|
|
674
|
+
}
|
|
675
|
+
|
|
534
676
|
private titleBar(width: number): string {
|
|
535
677
|
const left = "╭─" + this.theme.fg("accent", " btw ") + this.theme.fg("border", "─");
|
|
536
678
|
const labelWidth = Math.min(visibleWidth(this.modelLabel), Math.max(0, width - 14));
|