drexler 0.2.13 → 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.
@@ -1,5 +1,11 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { Children, memo, useMemo, type ReactNode } from "react";
3
+ import {
4
+ assistantDisplayLines,
5
+ firstDisplayLine,
6
+ normalizeAssistantDisplayContent,
7
+ type AssistantDisplayLine,
8
+ } from "./displayContent.ts";
3
9
  import { displayWidth, fitDisplayText, splitGraphemes } from "./graphemes.ts";
4
10
  import { useTheme } from "./ThemeContext.tsx";
5
11
 
@@ -21,13 +27,29 @@ export interface TranscriptViewportProps {
21
27
 
22
28
  interface TranscriptEntry {
23
29
  key: string;
24
- node: ReactNode;
30
+ node:
31
+ | ReactNode
32
+ | ((clipTo?: { readonly start: number; readonly rows: number }) => ReactNode);
25
33
  estimatedRows: number;
26
34
  }
27
35
 
36
+ interface VisibleEntry {
37
+ entry: TranscriptEntry;
38
+ clipStart?: number;
39
+ clipRows?: number;
40
+ }
41
+
28
42
  const DEFAULT_MAX_ROWS = 18;
29
43
  const DEFAULT_COLS = 80;
30
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
+ }
31
53
 
32
54
  const ROLE_LABELS: Record<TranscriptViewportItem["role"], string> = {
33
55
  user: "YOU",
@@ -41,6 +63,105 @@ const ROLE_MARKERS: Record<TranscriptViewportItem["role"], string> = {
41
63
  system: "!",
42
64
  };
43
65
 
66
+ const BODY_SUFFIX = " │";
67
+ const CONTINUATION_PREFIX = "│ ";
68
+ const CODE_GUTTER = "┃ ";
69
+ const DRACULA_CODE = {
70
+ text: "#f8f8f2",
71
+ keyword: "#ff79c6",
72
+ function: "#50fa7b",
73
+ string: "#f1fa8c",
74
+ number: "#bd93f9",
75
+ comment: "#6272a4",
76
+ operator: "#8be9fd",
77
+ gutter: "#6272a4",
78
+ };
79
+
80
+ interface WrappedTranscriptLine {
81
+ kind: AssistantDisplayLine["kind"];
82
+ text: string;
83
+ language?: string;
84
+ }
85
+
86
+ interface CodeToken {
87
+ kind:
88
+ | "plain"
89
+ | "keyword"
90
+ | "function"
91
+ | "string"
92
+ | "number"
93
+ | "comment"
94
+ | "operator";
95
+ text: string;
96
+ }
97
+
98
+ const CODE_KEYWORDS = new Set([
99
+ "and",
100
+ "as",
101
+ "async",
102
+ "await",
103
+ "break",
104
+ "case",
105
+ "catch",
106
+ "class",
107
+ "const",
108
+ "continue",
109
+ "def",
110
+ "default",
111
+ "do",
112
+ "elif",
113
+ "else",
114
+ "except",
115
+ "export",
116
+ "extends",
117
+ "false",
118
+ "finally",
119
+ "for",
120
+ "from",
121
+ "function",
122
+ "if",
123
+ "import",
124
+ "in",
125
+ "interface",
126
+ "let",
127
+ "new",
128
+ "none",
129
+ "not",
130
+ "null",
131
+ "or",
132
+ "pass",
133
+ "return",
134
+ "switch",
135
+ "throw",
136
+ "true",
137
+ "try",
138
+ "type",
139
+ "var",
140
+ "while",
141
+ "with",
142
+ "yield",
143
+ ]);
144
+
145
+ const CODE_OPERATOR_RE = /^[()[\]{}.,:;+\-*/%=<>!&|^~?]+/u;
146
+ const CODE_NUMBER_RE = /^\b(?:0x[\da-f]+|\d+(?:\.\d+)?)\b/iu;
147
+ const CODE_IDENTIFIER_RE = /^[A-Za-z_$][\w$]*/u;
148
+
149
+ function bodyPrefixForRole(role: TranscriptViewportItem["role"]): string {
150
+ if (role === "user") return "│ › ";
151
+ if (role === "assistant") return "│ ◆ ";
152
+ return CONTINUATION_PREFIX;
153
+ }
154
+
155
+ function transcriptContentWidth(
156
+ role: TranscriptViewportItem["role"],
157
+ cols: number,
158
+ ): number {
159
+ return Math.max(
160
+ 1,
161
+ cols - displayWidth(bodyPrefixForRole(role)) - displayWidth(BODY_SUFFIX),
162
+ );
163
+ }
164
+
44
165
  function wrapDisplayLine(input: string, maxWidth: number): string[] {
45
166
  const width = Math.max(1, maxWidth);
46
167
  if (input.length === 0) return [""];
@@ -91,10 +212,114 @@ function wrapDisplayLine(input: string, maxWidth: number): string[] {
91
212
  return rows.filter((row, index) => row.length > 0 || index === 0);
92
213
  }
93
214
 
94
- function wrappedContentRows(content: string, width: number): string[] {
95
- return content
96
- .split("\n")
97
- .flatMap((line) => wrapDisplayLine(line, width));
215
+ function displayContentForItem(item: TranscriptViewportItem): string {
216
+ if (item.role !== "assistant") return item.content;
217
+ return normalizeAssistantDisplayContent(item.content);
218
+ }
219
+
220
+ function displayLinesForItem(item: TranscriptViewportItem): AssistantDisplayLine[] {
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);
230
+ }
231
+
232
+ function wrappedTranscriptLines(
233
+ item: TranscriptViewportItem,
234
+ contentWidth: number,
235
+ ): WrappedTranscriptLine[] {
236
+ return displayLinesForItem(item).flatMap((line) => {
237
+ const width =
238
+ line.kind === "code"
239
+ ? Math.max(1, contentWidth - displayWidth(CODE_GUTTER))
240
+ : contentWidth;
241
+ return wrapDisplayLine(line.text, width).map((text) => ({
242
+ kind: line.kind,
243
+ text,
244
+ language: line.language,
245
+ }));
246
+ });
247
+ }
248
+
249
+ function tokenizeCodeLine(line: string): CodeToken[] {
250
+ const tokens: CodeToken[] = [];
251
+ let rest = line;
252
+
253
+ while (rest.length > 0) {
254
+ const comment =
255
+ rest.startsWith("#") || rest.startsWith("//") ? rest : undefined;
256
+ if (comment !== undefined) {
257
+ tokens.push({ kind: "comment", text: comment });
258
+ break;
259
+ }
260
+
261
+ const stringQuote = rest[0];
262
+ if (stringQuote === "\"" || stringQuote === "'" || stringQuote === "`") {
263
+ let end = 1;
264
+ let escaped = false;
265
+ while (end < rest.length) {
266
+ const char = rest[end];
267
+ if (escaped) {
268
+ escaped = false;
269
+ } else if (char === "\\") {
270
+ escaped = true;
271
+ } else if (char === stringQuote) {
272
+ end += 1;
273
+ break;
274
+ }
275
+ end += 1;
276
+ }
277
+ tokens.push({ kind: "string", text: rest.slice(0, end) });
278
+ rest = rest.slice(end);
279
+ continue;
280
+ }
281
+
282
+ const number = CODE_NUMBER_RE.exec(rest)?.[0];
283
+ if (number) {
284
+ tokens.push({ kind: "number", text: number });
285
+ rest = rest.slice(number.length);
286
+ continue;
287
+ }
288
+
289
+ const identifier = CODE_IDENTIFIER_RE.exec(rest)?.[0];
290
+ if (identifier) {
291
+ const after = rest.slice(identifier.length);
292
+ const kind =
293
+ CODE_KEYWORDS.has(identifier.toLowerCase())
294
+ ? "keyword"
295
+ : /^\s*\(/u.test(after)
296
+ ? "function"
297
+ : "plain";
298
+ tokens.push({ kind, text: identifier });
299
+ rest = rest.slice(identifier.length);
300
+ continue;
301
+ }
302
+
303
+ const operator = CODE_OPERATOR_RE.exec(rest)?.[0];
304
+ if (operator) {
305
+ tokens.push({ kind: "operator", text: operator });
306
+ rest = rest.slice(operator.length);
307
+ continue;
308
+ }
309
+
310
+ tokens.push({ kind: "plain", text: rest[0]! });
311
+ rest = rest.slice(1);
312
+ }
313
+
314
+ return tokens;
315
+ }
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);
98
323
  }
99
324
 
100
325
  function itemRows(
@@ -103,13 +328,8 @@ function itemRows(
103
328
  cols: number,
104
329
  ): number {
105
330
  if (compact) return 1;
106
- const bodyPrefix = "│ › ";
107
- const bodySuffix = " │";
108
- const contentWidth = Math.max(
109
- 1,
110
- cols - displayWidth(bodyPrefix) - displayWidth(bodySuffix),
111
- );
112
- return 2 + wrappedContentRows(item.content, contentWidth).length;
331
+ const contentWidth = transcriptContentWidth(item.role, cols);
332
+ return 2 + wrappedTranscriptLines(item, contentWidth).length;
113
333
  }
114
334
 
115
335
  function roleAccentColor(
@@ -137,20 +357,51 @@ function DefaultTranscriptItem({
137
357
  item,
138
358
  compact,
139
359
  cols,
360
+ clipStart = 0,
361
+ maxRows,
140
362
  }: {
141
363
  item: TranscriptViewportItem;
142
364
  compact: boolean;
143
365
  cols: number;
366
+ clipStart?: number;
367
+ maxRows?: number;
144
368
  }) {
145
369
  const t = useTheme();
146
370
  const label = ROLE_LABELS[item.role];
147
371
  const accent = roleAccentColor(item.role, t);
372
+ const renderCodeLine = (line: string) =>
373
+ tokenizeCodeLine(line).map((token, tokenIndex) => {
374
+ const color =
375
+ token.kind === "keyword"
376
+ ? DRACULA_CODE.keyword
377
+ : token.kind === "function"
378
+ ? DRACULA_CODE.function
379
+ : token.kind === "string"
380
+ ? DRACULA_CODE.string
381
+ : token.kind === "number" || token.kind === "operator"
382
+ ? token.kind === "number"
383
+ ? DRACULA_CODE.number
384
+ : DRACULA_CODE.operator
385
+ : token.kind === "comment"
386
+ ? DRACULA_CODE.comment
387
+ : DRACULA_CODE.text;
388
+ return (
389
+ <Text
390
+ key={tokenIndex}
391
+ color={color}
392
+ bold={token.kind === "keyword" || token.kind === "function"}
393
+ italic={token.kind === "comment"}
394
+ >
395
+ {token.text}
396
+ </Text>
397
+ );
398
+ });
148
399
 
149
- if (compact) {
400
+ if (compact || (maxRows !== undefined && maxRows < HEADER_FOOTER_ROWS + 1)) {
150
401
  const marker = item.role === "assistant" ? "◆" : ROLE_MARKERS[item.role];
151
402
  const prefix = `${label} ${marker} `;
152
403
  const budget = Math.max(1, cols - displayWidth(prefix));
153
- const firstLine = item.content.split("\n")[0] ?? "";
404
+ const firstLine = firstDisplayLine(displayContentForItem(item));
154
405
  return (
155
406
  <Box width={cols} flexShrink={1}>
156
407
  <Text color={accent} bold>
@@ -168,13 +419,49 @@ function DefaultTranscriptItem({
168
419
  const headerPrefix = `╭─ ${label} `;
169
420
  const headerRuleWidth = Math.max(0, cols - displayWidth(headerPrefix) - 1);
170
421
  const footerWidth = Math.max(0, cols - 2);
171
- const bodyPrefix = item.role === "user" ? "│ › " : "│ ";
172
- const continuationPrefix = "│ ";
173
- const bodySuffix = " │";
174
- const contentWidth = Math.max(
175
- 1,
176
- cols - displayWidth(bodyPrefix) - displayWidth(bodySuffix),
177
- );
422
+ const bodyPrefix = bodyPrefixForRole(item.role);
423
+ const contentWidth = transcriptContentWidth(item.role, cols);
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
+ }
178
465
 
179
466
  return (
180
467
  <Box flexDirection="column" width={cols} flexShrink={1}>
@@ -184,18 +471,39 @@ function DefaultTranscriptItem({
184
471
  cols,
185
472
  )}
186
473
  </Text>
187
- {wrappedContentRows(item.content, contentWidth).map((line, index) => (
474
+ {displayLines.map((line, index) => (
188
475
  <Box key={index} width={cols} flexShrink={1}>
189
476
  <Text color={accent} bold={item.role === "user"}>
190
- {index === 0 ? bodyPrefix : continuationPrefix}
477
+ {index === 0 ? bodyPrefix : CONTINUATION_PREFIX}
191
478
  </Text>
192
- <Text color={roleBodyColor(item.role, t)}>
193
- {fitDisplayText(line, contentWidth)}
479
+ {line.kind === "code" ? (
480
+ <Text color={DRACULA_CODE.gutter}>{CODE_GUTTER}</Text>
481
+ ) : null}
482
+ <Text
483
+ color={
484
+ line.kind === "code"
485
+ ? DRACULA_CODE.text
486
+ : roleBodyColor(item.role, t)
487
+ }
488
+ >
489
+ {line.kind === "code"
490
+ ? renderCodeLine(
491
+ fitDisplayText(
492
+ line.text,
493
+ Math.max(1, contentWidth - displayWidth(CODE_GUTTER)),
494
+ ),
495
+ )
496
+ : fitDisplayText(line.text, contentWidth)}
194
497
  </Text>
195
498
  <Text color={accent} bold={item.role === "user"}>
196
499
  {`${" ".repeat(
197
- Math.max(0, contentWidth - displayWidth(line)),
198
- )}${bodySuffix}`}
500
+ Math.max(
501
+ 0,
502
+ contentWidth -
503
+ displayWidth(line.text) -
504
+ (line.kind === "code" ? displayWidth(CODE_GUTTER) : 0),
505
+ ),
506
+ )}${BODY_SUFFIX}`}
199
507
  </Text>
200
508
  </Box>
201
509
  ))}
@@ -227,11 +535,17 @@ function itemsToEntries({
227
535
  }): TranscriptEntry[] {
228
536
  return items.map((item, index) => ({
229
537
  key: String(item.id ?? index),
230
- node: renderItem ? (
231
- renderItem(item, index)
232
- ) : (
233
- <DefaultTranscriptItem item={item} compact={compact} cols={cols} />
234
- ),
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
+ ),
235
549
  estimatedRows: itemRows(item, compact, cols),
236
550
  }));
237
551
  }
@@ -241,71 +555,134 @@ function selectWindow(
241
555
  maxRows: number,
242
556
  scrollOffset: number,
243
557
  ): {
244
- visible: TranscriptEntry[];
558
+ visible: VisibleEntry[];
245
559
  hiddenBefore: number;
246
560
  hiddenAfter: number;
561
+ hiddenRowsBefore: number;
562
+ hiddenRowsAfter: number;
247
563
  } {
248
564
  if (entries.length === 0) {
249
- return { visible: [], hiddenBefore: 0, hiddenAfter: 0 };
565
+ return {
566
+ visible: [],
567
+ hiddenBefore: 0,
568
+ hiddenAfter: 0,
569
+ hiddenRowsBefore: 0,
570
+ hiddenRowsAfter: 0,
571
+ };
250
572
  }
251
573
 
252
574
  const safeRows = Math.max(1, Math.floor(maxRows));
253
- const safeOffset = Math.max(0, Math.min(Math.floor(scrollOffset), entries.length - 1));
254
- const end = entries.length - safeOffset;
255
- let reserveTop = 0;
256
- const reserveBottom = safeOffset > 0 ? 1 : 0;
257
- 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);
258
582
 
259
583
  for (let pass = 0; pass < 3; pass++) {
260
- const budget = Math.max(1, safeRows - reserveTop - reserveBottom);
261
- let used = 0;
262
- start = end;
263
-
264
- while (start > 0) {
265
- const entry = entries[start - 1]!;
266
- const rows = Math.max(1, entry.estimatedRows);
267
- if (used > 0 && used + rows > budget) break;
268
- start -= 1;
269
- used += rows;
270
- if (used >= budget) break;
271
- }
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
+ }
272
594
 
273
- const nextReserveTop = start > 0 ? 1 : 0;
274
- if (nextReserveTop === reserveTop) break;
275
- 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);
276
629
  }
277
630
 
278
- const visible = entries.slice(start, end);
279
- const visibleRows = visible.reduce(
280
- (sum, entry) => sum + Math.max(1, entry.estimatedRows),
281
- 0,
282
- );
283
- let hiddenBefore = start;
284
- let hiddenAfter = entries.length - end;
285
- if (visibleRows + (hiddenBefore > 0 ? 1 : 0) + (hiddenAfter > 0 ? 1 : 0) > safeRows) {
286
- if (hiddenBefore > 0) hiddenBefore = 0;
287
- 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;
288
656
  }
289
657
 
290
- return { visible, hiddenBefore, hiddenAfter };
658
+ return {
659
+ visible,
660
+ hiddenBefore,
661
+ hiddenAfter,
662
+ hiddenRowsBefore,
663
+ hiddenRowsAfter,
664
+ };
291
665
  }
292
666
 
293
667
  function ScrollIndicator({
294
668
  direction,
295
669
  count,
670
+ rows,
296
671
  compact,
297
672
  cols,
298
673
  }: {
299
674
  direction: "earlier" | "newer";
300
675
  count: number;
676
+ rows: number;
301
677
  compact: boolean;
302
678
  cols: number;
303
679
  }) {
304
680
  const t = useTheme();
305
681
  const arrow = direction === "earlier" ? "↑" : "↓";
682
+ const keyHint = direction === "earlier" ? "PageUp scrollback" : "PageDown newer";
306
683
  const label = compact
307
- ? `${arrow} ${count} ${direction}`
308
- : `${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}`;
309
686
 
310
687
  return (
311
688
  <Box width={cols} flexShrink={1}>
@@ -333,7 +710,13 @@ function TranscriptViewportInner({
333
710
  : childrenToEntries(children),
334
711
  [children, compact, items, renderItem, width],
335
712
  );
336
- const { visible, hiddenBefore, hiddenAfter } = useMemo(
713
+ const {
714
+ visible,
715
+ hiddenBefore,
716
+ hiddenAfter,
717
+ hiddenRowsBefore,
718
+ hiddenRowsAfter,
719
+ } = useMemo(
337
720
  () => selectWindow(entries, maxRows, scrollOffset),
338
721
  [entries, maxRows, scrollOffset],
339
722
  );
@@ -344,19 +727,27 @@ function TranscriptViewportInner({
344
727
  <ScrollIndicator
345
728
  direction="earlier"
346
729
  count={hiddenBefore}
730
+ rows={hiddenRowsBefore}
347
731
  compact={compact}
348
732
  cols={width}
349
733
  />
350
734
  ) : null}
351
- {visible.map((entry) => (
735
+ {visible.map(({ entry, clipStart, clipRows }) => (
352
736
  <Box key={entry.key} flexDirection="column" width={width} flexShrink={1}>
353
- {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}
354
744
  </Box>
355
745
  ))}
356
746
  {hiddenAfter > 0 ? (
357
747
  <ScrollIndicator
358
748
  direction="newer"
359
749
  count={hiddenAfter}
750
+ rows={hiddenRowsAfter}
360
751
  compact={compact}
361
752
  cols={width}
362
753
  />