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 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.1.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, `c` copy the selected
13
- * answer, `f` promote the selected exchange into the main conversation, `x`
14
- * delete the selected exchange, Esc/Enter close.
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 lists
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
- this.contentLines = this.buildContent(innerWidth);
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 scrolls back to the expanded newest exchange.
295
- // The exchange appearing in the body is the whole signal; no footer
296
- // flash is needed for it.
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.setScroll(0);
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.setScroll(0);
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 expanded exchange is the first block of the body, so browsing
389
- // always restarts at its top.
390
- this.setScroll(0);
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 container = new Container();
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
- container.addChild(new Text(this.questionLine(pending.question), 0, 0));
460
- container.addChild(
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
- container.addChild(
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
- container.addChild(new Text(this.theme.fg("dim", `${count} side question${count === 1 ? "" : "s"}`), 0, 0));
476
- container.addChild(new Text(this.questionLine(selected.question), 0, 1));
477
- container.addChild(new Markdown(selected.answer, 0, 1, getMarkdownTheme()));
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
- container.addChild(new Spacer(1));
483
- container.addChild(new Text(this.theme.fg("borderMuted", "─".repeat(innerWidth)), 0, 0));
484
- container.addChild(new Spacer(1));
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
- container.addChild(new Text(this.theme.fg("dim", `… ${start} earlier`), 0, 0));
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
- container.addChild(new Text(line, 0, 0));
618
+ push(new Text(line, 0, 0));
496
619
  }
497
620
  if (end < this.thread.length) {
498
- container.addChild(new Text(this.theme.fg("dim", `… ${this.thread.length - end} newer`), 0, 0));
621
+ push(new Text(this.theme.fg("dim", `… ${this.thread.length - end} newer`), 0, 0));
499
622
  }
500
623
  }
501
624
 
502
- return container.render(innerWidth);
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));