drexler 0.2.14 → 0.2.15

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.
@@ -27,13 +27,29 @@ export interface TranscriptViewportProps {
27
27
 
28
28
  interface TranscriptEntry {
29
29
  key: string;
30
- node: ReactNode;
30
+ node:
31
+ | ReactNode
32
+ | ((clipTo?: { readonly start: number; readonly rows: number }) => ReactNode);
31
33
  estimatedRows: number;
32
34
  }
33
35
 
36
+ interface VisibleEntry {
37
+ entry: TranscriptEntry;
38
+ clipStart?: number;
39
+ clipRows?: number;
40
+ }
41
+
34
42
  const DEFAULT_MAX_ROWS = 18;
35
43
  const DEFAULT_COLS = 80;
36
44
  const MIN_COLS = 1;
45
+ const HEADER_FOOTER_ROWS = 2;
46
+ const TRUNCATION_HINT_ROWS = 1;
47
+ const MIN_TRUNCATED_BODY_ROWS = 1;
48
+
49
+ function truncationHint(dropped: number, direction: "earlier" | "newer"): string {
50
+ const keyHint = direction === "earlier" ? "PageUp scrollback" : "PageDown newer";
51
+ return `... ${dropped} line${dropped === 1 ? "" : "s"} ${direction} — ${keyHint} to read`;
52
+ }
37
53
 
38
54
  const ROLE_LABELS: Record<TranscriptViewportItem["role"], string> = {
39
55
  user: "YOU",
@@ -202,8 +218,15 @@ function displayContentForItem(item: TranscriptViewportItem): string {
202
218
  }
203
219
 
204
220
  function displayLinesForItem(item: TranscriptViewportItem): AssistantDisplayLine[] {
205
- if (item.role === "assistant") return assistantDisplayLines(item.content);
206
- return item.content.split("\n").map((text) => ({ kind: "text", text }));
221
+ const lines =
222
+ item.role === "assistant"
223
+ ? assistantDisplayLines(item.content)
224
+ : item.content.split("\n").map((text) => ({ kind: "text" as const, text }));
225
+ let start = 0;
226
+ let end = lines.length;
227
+ while (start < end && lines[start]?.text.trim().length === 0) start += 1;
228
+ while (end > start && lines[end - 1]?.text.trim().length === 0) end -= 1;
229
+ return lines.slice(start, end);
207
230
  }
208
231
 
209
232
  function wrappedTranscriptLines(
@@ -291,6 +314,14 @@ function tokenizeCodeLine(line: string): CodeToken[] {
291
314
  return tokens;
292
315
  }
293
316
 
317
+ export function estimateTranscriptRows(
318
+ items: readonly TranscriptViewportItem[],
319
+ compact: boolean,
320
+ cols: number,
321
+ ): number {
322
+ return items.reduce((sum, item) => sum + itemRows(item, compact, cols), 0);
323
+ }
324
+
294
325
  function itemRows(
295
326
  item: TranscriptViewportItem,
296
327
  compact: boolean,
@@ -326,10 +357,14 @@ function DefaultTranscriptItem({
326
357
  item,
327
358
  compact,
328
359
  cols,
360
+ clipStart = 0,
361
+ maxRows,
329
362
  }: {
330
363
  item: TranscriptViewportItem;
331
364
  compact: boolean;
332
365
  cols: number;
366
+ clipStart?: number;
367
+ maxRows?: number;
333
368
  }) {
334
369
  const t = useTheme();
335
370
  const label = ROLE_LABELS[item.role];
@@ -362,7 +397,7 @@ function DefaultTranscriptItem({
362
397
  );
363
398
  });
364
399
 
365
- if (compact) {
400
+ if (compact || (maxRows !== undefined && maxRows < HEADER_FOOTER_ROWS + 1)) {
366
401
  const marker = item.role === "assistant" ? "◆" : ROLE_MARKERS[item.role];
367
402
  const prefix = `${label} ${marker} `;
368
403
  const budget = Math.max(1, cols - displayWidth(prefix));
@@ -386,7 +421,47 @@ function DefaultTranscriptItem({
386
421
  const footerWidth = Math.max(0, cols - 2);
387
422
  const bodyPrefix = bodyPrefixForRole(item.role);
388
423
  const contentWidth = transcriptContentWidth(item.role, cols);
389
- const displayLines = wrappedTranscriptLines(item, contentWidth);
424
+ const allDisplayLines = wrappedTranscriptLines(item, contentWidth);
425
+ let displayLines: WrappedTranscriptLine[] = allDisplayLines;
426
+ if (maxRows !== undefined) {
427
+ const bodyBudget = Math.max(
428
+ MIN_TRUNCATED_BODY_ROWS,
429
+ maxRows - HEADER_FOOTER_ROWS,
430
+ );
431
+ const bodyStart = Math.max(0, Math.min(allDisplayLines.length, clipStart - 1));
432
+ const before = bodyStart;
433
+ const afterAvailable = Math.max(0, allDisplayLines.length - bodyStart);
434
+ const needsTopHint = before > 0;
435
+ const needsBottomHint = afterAvailable > bodyBudget;
436
+ const hintRows =
437
+ (needsTopHint ? TRUNCATION_HINT_ROWS : 0) +
438
+ (needsBottomHint ? TRUNCATION_HINT_ROWS : 0);
439
+ const keep = Math.max(0, bodyBudget - hintRows);
440
+ const bodyEnd = Math.min(allDisplayLines.length, bodyStart + keep);
441
+ if (needsTopHint || bodyEnd < allDisplayLines.length) {
442
+ const droppedBefore = before;
443
+ const droppedAfter = allDisplayLines.length - bodyEnd;
444
+ displayLines = [
445
+ ...(droppedBefore > 0
446
+ ? [
447
+ {
448
+ kind: "text" as const,
449
+ text: truncationHint(droppedBefore, "earlier"),
450
+ },
451
+ ]
452
+ : []),
453
+ ...allDisplayLines.slice(bodyStart, bodyEnd),
454
+ ...(droppedAfter > 0
455
+ ? [
456
+ {
457
+ kind: "text" as const,
458
+ text: truncationHint(droppedAfter, "newer"),
459
+ },
460
+ ]
461
+ : []),
462
+ ];
463
+ }
464
+ }
390
465
 
391
466
  return (
392
467
  <Box flexDirection="column" width={cols} flexShrink={1}>
@@ -460,11 +535,17 @@ function itemsToEntries({
460
535
  }): TranscriptEntry[] {
461
536
  return items.map((item, index) => ({
462
537
  key: String(item.id ?? index),
463
- node: renderItem ? (
464
- renderItem(item, index)
465
- ) : (
466
- <DefaultTranscriptItem item={item} compact={compact} cols={cols} />
467
- ),
538
+ node: renderItem
539
+ ? renderItem(item, index)
540
+ : (clipTo?: { readonly start: number; readonly rows: number }) => (
541
+ <DefaultTranscriptItem
542
+ item={item}
543
+ compact={compact}
544
+ cols={cols}
545
+ clipStart={clipTo?.start}
546
+ maxRows={clipTo?.rows}
547
+ />
548
+ ),
468
549
  estimatedRows: itemRows(item, compact, cols),
469
550
  }));
470
551
  }
@@ -474,71 +555,134 @@ function selectWindow(
474
555
  maxRows: number,
475
556
  scrollOffset: number,
476
557
  ): {
477
- visible: TranscriptEntry[];
558
+ visible: VisibleEntry[];
478
559
  hiddenBefore: number;
479
560
  hiddenAfter: number;
561
+ hiddenRowsBefore: number;
562
+ hiddenRowsAfter: number;
480
563
  } {
481
564
  if (entries.length === 0) {
482
- return { visible: [], hiddenBefore: 0, hiddenAfter: 0 };
565
+ return {
566
+ visible: [],
567
+ hiddenBefore: 0,
568
+ hiddenAfter: 0,
569
+ hiddenRowsBefore: 0,
570
+ hiddenRowsAfter: 0,
571
+ };
483
572
  }
484
573
 
485
574
  const safeRows = Math.max(1, Math.floor(maxRows));
486
- const safeOffset = Math.max(0, Math.min(Math.floor(scrollOffset), entries.length - 1));
487
- const end = entries.length - safeOffset;
488
- let reserveTop = 0;
489
- const reserveBottom = safeOffset > 0 ? 1 : 0;
490
- let start = Math.max(0, end - 1);
575
+ const rowCounts = entries.map((entry) => Math.max(1, entry.estimatedRows));
576
+ const totalRows = rowCounts.reduce((sum, rows) => sum + rows, 0);
577
+ const maxOffset = Math.max(0, totalRows - 1);
578
+ const safeOffset = Math.max(0, Math.min(Math.floor(scrollOffset), maxOffset));
579
+ const endRow = Math.max(1, totalRows - safeOffset);
580
+ let indicatorRows = 0;
581
+ let startRow = Math.max(0, endRow - safeRows);
491
582
 
492
583
  for (let pass = 0; pass < 3; pass++) {
493
- const budget = Math.max(1, safeRows - reserveTop - reserveBottom);
494
- let used = 0;
495
- start = end;
496
-
497
- while (start > 0) {
498
- const entry = entries[start - 1]!;
499
- const rows = Math.max(1, entry.estimatedRows);
500
- if (used > 0 && used + rows > budget) break;
501
- start -= 1;
502
- used += rows;
503
- if (used >= budget) break;
504
- }
584
+ const hiddenBeforeRows = startRow;
585
+ const hiddenAfterRows = Math.max(0, totalRows - endRow);
586
+ const nextIndicatorRows = Math.min(
587
+ safeRows - 1,
588
+ (hiddenBeforeRows > 0 ? 1 : 0) + (hiddenAfterRows > 0 ? 1 : 0),
589
+ );
590
+ if (nextIndicatorRows === indicatorRows) break;
591
+ indicatorRows = nextIndicatorRows;
592
+ startRow = Math.max(0, endRow - Math.max(1, safeRows - indicatorRows));
593
+ }
505
594
 
506
- const nextReserveTop = start > 0 ? 1 : 0;
507
- if (nextReserveTop === reserveTop) break;
508
- reserveTop = nextReserveTop;
595
+ const buildVisible = (fromRow: number, toRow: number): VisibleEntry[] => {
596
+ const next: VisibleEntry[] = [];
597
+ let rowCursor = 0;
598
+ for (let i = 0; i < entries.length; i++) {
599
+ const entry = entries[i]!;
600
+ const rows = rowCounts[i]!;
601
+ const entryStart = rowCursor;
602
+ const entryEnd = rowCursor + rows;
603
+ rowCursor = entryEnd;
604
+ const overlapStart = Math.max(fromRow, entryStart);
605
+ const overlapEnd = Math.min(toRow, entryEnd);
606
+ if (overlapStart >= overlapEnd) continue;
607
+ next.push({
608
+ entry,
609
+ clipStart: overlapStart - entryStart,
610
+ clipRows: overlapEnd - overlapStart,
611
+ });
612
+ }
613
+ return next;
614
+ };
615
+
616
+ let visible = buildVisible(startRow, endRow);
617
+ if (
618
+ indicatorRows > 0 &&
619
+ visible.some(
620
+ ({ entry, clipRows }) =>
621
+ entry.estimatedRows >= HEADER_FOOTER_ROWS + MIN_TRUNCATED_BODY_ROWS &&
622
+ (clipRows ?? entry.estimatedRows) <
623
+ HEADER_FOOTER_ROWS + MIN_TRUNCATED_BODY_ROWS,
624
+ )
625
+ ) {
626
+ indicatorRows = 0;
627
+ startRow = Math.max(0, endRow - safeRows);
628
+ visible = buildVisible(startRow, endRow);
509
629
  }
510
630
 
511
- const visible = entries.slice(start, end);
512
- const visibleRows = visible.reduce(
513
- (sum, entry) => sum + Math.max(1, entry.estimatedRows),
514
- 0,
515
- );
516
- let hiddenBefore = start;
517
- let hiddenAfter = entries.length - end;
518
- if (visibleRows + (hiddenBefore > 0 ? 1 : 0) + (hiddenAfter > 0 ? 1 : 0) > safeRows) {
519
- if (hiddenBefore > 0) hiddenBefore = 0;
520
- if (visibleRows + (hiddenAfter > 0 ? 1 : 0) > safeRows) hiddenAfter = 0;
631
+ let hiddenBefore = 0;
632
+ let hiddenAfter = 0;
633
+ let cursor = 0;
634
+ for (let i = 0; i < entries.length; i++) {
635
+ const rows = rowCounts[i]!;
636
+ const entryStart = cursor;
637
+ const entryEnd = cursor + rows;
638
+ cursor = entryEnd;
639
+ if (entryEnd <= startRow) hiddenBefore += 1;
640
+ else if (entryStart < startRow && entryEnd > startRow) hiddenBefore += 1;
641
+ if (entryStart >= endRow) hiddenAfter += 1;
642
+ else if (entryStart < endRow && entryEnd > endRow) hiddenAfter += 1;
643
+ }
644
+ let hiddenRowsBefore = startRow;
645
+ let hiddenRowsAfter = Math.max(0, totalRows - endRow);
646
+ const showTopIndicator = indicatorRows > 0 && hiddenRowsBefore > 0;
647
+ const showBottomIndicator =
648
+ indicatorRows > (showTopIndicator ? 1 : 0) && hiddenRowsAfter > 0;
649
+ if (!showTopIndicator) {
650
+ hiddenBefore = 0;
651
+ hiddenRowsBefore = 0;
652
+ }
653
+ if (!showBottomIndicator) {
654
+ hiddenAfter = 0;
655
+ hiddenRowsAfter = 0;
521
656
  }
522
657
 
523
- return { visible, hiddenBefore, hiddenAfter };
658
+ return {
659
+ visible,
660
+ hiddenBefore,
661
+ hiddenAfter,
662
+ hiddenRowsBefore,
663
+ hiddenRowsAfter,
664
+ };
524
665
  }
525
666
 
526
667
  function ScrollIndicator({
527
668
  direction,
528
669
  count,
670
+ rows,
529
671
  compact,
530
672
  cols,
531
673
  }: {
532
674
  direction: "earlier" | "newer";
533
675
  count: number;
676
+ rows: number;
534
677
  compact: boolean;
535
678
  cols: number;
536
679
  }) {
537
680
  const t = useTheme();
538
681
  const arrow = direction === "earlier" ? "↑" : "↓";
682
+ const keyHint = direction === "earlier" ? "PageUp scrollback" : "PageDown newer";
539
683
  const label = compact
540
- ? `${arrow} ${count} ${direction}`
541
- : `${arrow} ${count} ${direction} transcript item${count === 1 ? "" : "s"} hidden`;
684
+ ? `${arrow} ${rows} ${direction}`
685
+ : `${arrow} ${rows} line${rows === 1 ? "" : "s"} ${direction} (${count} item${count === 1 ? "" : "s"} hidden) — ${keyHint}`;
542
686
 
543
687
  return (
544
688
  <Box width={cols} flexShrink={1}>
@@ -566,7 +710,13 @@ function TranscriptViewportInner({
566
710
  : childrenToEntries(children),
567
711
  [children, compact, items, renderItem, width],
568
712
  );
569
- const { visible, hiddenBefore, hiddenAfter } = useMemo(
713
+ const {
714
+ visible,
715
+ hiddenBefore,
716
+ hiddenAfter,
717
+ hiddenRowsBefore,
718
+ hiddenRowsAfter,
719
+ } = useMemo(
570
720
  () => selectWindow(entries, maxRows, scrollOffset),
571
721
  [entries, maxRows, scrollOffset],
572
722
  );
@@ -577,19 +727,27 @@ function TranscriptViewportInner({
577
727
  <ScrollIndicator
578
728
  direction="earlier"
579
729
  count={hiddenBefore}
730
+ rows={hiddenRowsBefore}
580
731
  compact={compact}
581
732
  cols={width}
582
733
  />
583
734
  ) : null}
584
- {visible.map((entry) => (
735
+ {visible.map(({ entry, clipStart, clipRows }) => (
585
736
  <Box key={entry.key} flexDirection="column" width={width} flexShrink={1}>
586
- {entry.node}
737
+ {typeof entry.node === "function"
738
+ ? entry.node(
739
+ clipRows === undefined
740
+ ? undefined
741
+ : { start: clipStart ?? 0, rows: clipRows },
742
+ )
743
+ : entry.node}
587
744
  </Box>
588
745
  ))}
589
746
  {hiddenAfter > 0 ? (
590
747
  <ScrollIndicator
591
748
  direction="newer"
592
749
  count={hiddenAfter}
750
+ rows={hiddenRowsAfter}
593
751
  compact={compact}
594
752
  cols={width}
595
753
  />
@@ -76,15 +76,17 @@ export function normalizeAssistantMarkdownRenderContent(content: string): string
76
76
  const output: string[] = [];
77
77
  let fenceChar = "";
78
78
  let fenceLength = 0;
79
+ let fenceMarker = "";
79
80
  let markdownFence = false;
80
81
 
81
82
  for (const rawLine of lines) {
82
83
  const line = rawLine.replace(/\t/gu, TAB_DISPLAY);
83
84
  if (fenceChar.length > 0) {
84
85
  if (isFenceClose(line, fenceChar, fenceLength)) {
85
- if (!markdownFence) output.push("```");
86
+ if (!markdownFence) output.push(fenceMarker);
86
87
  fenceChar = "";
87
88
  fenceLength = 0;
89
+ fenceMarker = "";
88
90
  markdownFence = false;
89
91
  } else {
90
92
  output.push(line);
@@ -98,8 +100,9 @@ export function normalizeAssistantMarkdownRenderContent(content: string): string
98
100
  const info = openingFence[2] ?? "";
99
101
  fenceChar = marker[0]!;
100
102
  fenceLength = marker.length;
103
+ fenceMarker = marker;
101
104
  markdownFence = isMarkdownFence(info);
102
- if (!markdownFence) output.push("```");
105
+ if (!markdownFence) output.push(marker);
103
106
  continue;
104
107
  }
105
108