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.
- package/CHANGELOG.md +8 -0
- package/README.md +64 -13
- package/package.json +1 -1
- package/src/commands.ts +127 -20
- package/src/config.ts +141 -32
- package/src/conversation.ts +0 -4
- package/src/index.ts +69 -6
- package/src/pet/petState.ts +408 -0
- package/src/repl.ts +1 -1
- package/src/ui/App.tsx +557 -146
- package/src/ui/CommandPalette.tsx +2 -0
- package/src/ui/DealDeskHeader.tsx +245 -77
- package/src/ui/DeathScreen.tsx +110 -0
- package/src/ui/MarkdownBody.tsx +406 -0
- package/src/ui/MascotIntro.tsx +713 -111
- package/src/ui/Message.tsx +24 -114
- package/src/ui/PetPanel.tsx +537 -0
- package/src/ui/SynergyEvent.tsx +3 -2
- package/src/ui/TranscriptViewport.tsx +461 -70
- package/src/ui/displayContent.ts +117 -0
- package/src/ui/graphemes.ts +1 -0
|
@@ -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:
|
|
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
|
|
95
|
-
return content
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
107
|
-
|
|
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
|
|
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
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
{
|
|
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 :
|
|
477
|
+
{index === 0 ? bodyPrefix : CONTINUATION_PREFIX}
|
|
191
478
|
</Text>
|
|
192
|
-
|
|
193
|
-
{
|
|
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(
|
|
198
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
if (
|
|
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 {
|
|
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} ${
|
|
308
|
-
: `${arrow} ${
|
|
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 {
|
|
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
|
/>
|