drexler 0.2.12 → 0.2.14
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 +15 -0
- package/README.md +58 -13
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/ui/App.tsx +26 -14
- package/src/ui/CommandPalette.tsx +23 -13
- package/src/ui/DealDeskHeader.tsx +248 -80
- package/src/ui/MarkdownBody.tsx +382 -0
- package/src/ui/MascotIntro.tsx +568 -73
- package/src/ui/Message.tsx +28 -15
- package/src/ui/Spinner.tsx +11 -9
- package/src/ui/StatusBar.tsx +11 -43
- package/src/ui/SynergyEvent.tsx +3 -2
- package/src/ui/TranscriptViewport.tsx +271 -30
- package/src/ui/displayContent.ts +114 -0
- package/src/ui/graphemes.ts +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { Fragment, memo, useMemo, type ReactNode } from "react";
|
|
3
|
+
import { displayWidth } from "./graphemes.ts";
|
|
4
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
5
|
+
|
|
6
|
+
interface InlineToken {
|
|
7
|
+
text: string;
|
|
8
|
+
bold?: boolean;
|
|
9
|
+
italic?: boolean;
|
|
10
|
+
code?: boolean;
|
|
11
|
+
link?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Block =
|
|
15
|
+
| { kind: "para"; lines: string[] }
|
|
16
|
+
| { kind: "heading"; level: number; line: string }
|
|
17
|
+
| { kind: "bullet"; marker: string; indent: number; line: string }
|
|
18
|
+
| { kind: "quote"; lines: string[] }
|
|
19
|
+
| { kind: "code"; lang?: string; lines: string[] }
|
|
20
|
+
| { kind: "hr" }
|
|
21
|
+
| { kind: "blank" };
|
|
22
|
+
|
|
23
|
+
const BULLET_RE = /^(\s*)([*+\-]|\d+\.)\s+(.*)$/;
|
|
24
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
|
25
|
+
const HR_RE = /^\s*([-*_])\1\1[-*_\s]*$/;
|
|
26
|
+
const FENCE_RE = /^\s*```(.*)$/;
|
|
27
|
+
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
|
28
|
+
|
|
29
|
+
export function tokenizeInline(input: string): InlineToken[] {
|
|
30
|
+
const tokens: InlineToken[] = [];
|
|
31
|
+
let buf = "";
|
|
32
|
+
let i = 0;
|
|
33
|
+
const flushBuf = () => {
|
|
34
|
+
if (buf.length > 0) {
|
|
35
|
+
tokens.push({ text: buf });
|
|
36
|
+
buf = "";
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
while (i < input.length) {
|
|
41
|
+
const ch = input[i]!;
|
|
42
|
+
const next = input[i + 1];
|
|
43
|
+
|
|
44
|
+
if (ch === "`") {
|
|
45
|
+
const end = input.indexOf("`", i + 1);
|
|
46
|
+
if (end !== -1 && end > i + 1) {
|
|
47
|
+
flushBuf();
|
|
48
|
+
tokens.push({ text: input.slice(i + 1, end), code: true });
|
|
49
|
+
i = end + 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if ((ch === "*" && next === "*") || (ch === "_" && next === "_")) {
|
|
55
|
+
const marker = `${ch}${ch}`;
|
|
56
|
+
const end = input.indexOf(marker, i + 2);
|
|
57
|
+
if (end !== -1 && end > i + 2) {
|
|
58
|
+
flushBuf();
|
|
59
|
+
const inner = input.slice(i + 2, end);
|
|
60
|
+
for (const sub of tokenizeInline(inner)) {
|
|
61
|
+
tokens.push({ ...sub, bold: true });
|
|
62
|
+
}
|
|
63
|
+
i = end + 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (ch === "*" || ch === "_") {
|
|
69
|
+
const prev = input[i - 1];
|
|
70
|
+
if (next !== undefined && next !== ch && next !== " " && next !== "\t") {
|
|
71
|
+
let end = -1;
|
|
72
|
+
for (let j = i + 1; j < input.length; j++) {
|
|
73
|
+
if (input[j] === ch && input[j - 1] !== " " && input[j + 1] !== ch && input[j - 1] !== ch) {
|
|
74
|
+
end = j;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (end !== -1 && (prev === undefined || /\s|[(\[{]/.test(prev))) {
|
|
79
|
+
flushBuf();
|
|
80
|
+
tokens.push({ text: input.slice(i + 1, end), italic: true });
|
|
81
|
+
i = end + 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (ch === "[") {
|
|
88
|
+
const closeBracket = input.indexOf("]", i + 1);
|
|
89
|
+
if (closeBracket !== -1 && input[closeBracket + 1] === "(") {
|
|
90
|
+
const closeParen = input.indexOf(")", closeBracket + 2);
|
|
91
|
+
if (closeParen !== -1) {
|
|
92
|
+
flushBuf();
|
|
93
|
+
const text = input.slice(i + 1, closeBracket);
|
|
94
|
+
const url = input.slice(closeBracket + 2, closeParen);
|
|
95
|
+
tokens.push({ text, link: url });
|
|
96
|
+
i = closeParen + 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
buf += ch;
|
|
103
|
+
i += 1;
|
|
104
|
+
}
|
|
105
|
+
flushBuf();
|
|
106
|
+
return tokens;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function parseBlocks(input: string): Block[] {
|
|
110
|
+
const lines = input.split("\n");
|
|
111
|
+
const blocks: Block[] = [];
|
|
112
|
+
let i = 0;
|
|
113
|
+
|
|
114
|
+
while (i < lines.length) {
|
|
115
|
+
const line = lines[i]!;
|
|
116
|
+
const fence = FENCE_RE.exec(line);
|
|
117
|
+
if (fence) {
|
|
118
|
+
const lang = fence[1]?.trim() || undefined;
|
|
119
|
+
const codeLines: string[] = [];
|
|
120
|
+
i += 1;
|
|
121
|
+
while (i < lines.length && !FENCE_RE.test(lines[i]!)) {
|
|
122
|
+
codeLines.push(lines[i]!);
|
|
123
|
+
i += 1;
|
|
124
|
+
}
|
|
125
|
+
i += 1;
|
|
126
|
+
blocks.push({ kind: "code", lang, lines: codeLines });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (line.trim().length === 0) {
|
|
131
|
+
blocks.push({ kind: "blank" });
|
|
132
|
+
i += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (HR_RE.test(line)) {
|
|
137
|
+
blocks.push({ kind: "hr" });
|
|
138
|
+
i += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const heading = HEADING_RE.exec(line);
|
|
143
|
+
if (heading) {
|
|
144
|
+
blocks.push({
|
|
145
|
+
kind: "heading",
|
|
146
|
+
level: heading[1]!.length,
|
|
147
|
+
line: heading[2]!,
|
|
148
|
+
});
|
|
149
|
+
i += 1;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const quote = QUOTE_RE.exec(line);
|
|
154
|
+
if (quote) {
|
|
155
|
+
const quoteLines: string[] = [quote[1] ?? ""];
|
|
156
|
+
i += 1;
|
|
157
|
+
while (i < lines.length) {
|
|
158
|
+
const m = QUOTE_RE.exec(lines[i]!);
|
|
159
|
+
if (!m) break;
|
|
160
|
+
quoteLines.push(m[1] ?? "");
|
|
161
|
+
i += 1;
|
|
162
|
+
}
|
|
163
|
+
blocks.push({ kind: "quote", lines: quoteLines });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const bullet = BULLET_RE.exec(line);
|
|
168
|
+
if (bullet) {
|
|
169
|
+
blocks.push({
|
|
170
|
+
kind: "bullet",
|
|
171
|
+
marker: bullet[2]!,
|
|
172
|
+
indent: bullet[1]!.length,
|
|
173
|
+
line: bullet[3]!,
|
|
174
|
+
});
|
|
175
|
+
i += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const paraLines: string[] = [line];
|
|
180
|
+
i += 1;
|
|
181
|
+
while (i < lines.length) {
|
|
182
|
+
const peek = lines[i]!;
|
|
183
|
+
if (
|
|
184
|
+
peek.trim().length === 0 ||
|
|
185
|
+
FENCE_RE.test(peek) ||
|
|
186
|
+
HR_RE.test(peek) ||
|
|
187
|
+
HEADING_RE.test(peek) ||
|
|
188
|
+
QUOTE_RE.test(peek) ||
|
|
189
|
+
BULLET_RE.test(peek)
|
|
190
|
+
) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
paraLines.push(peek);
|
|
194
|
+
i += 1;
|
|
195
|
+
}
|
|
196
|
+
blocks.push({ kind: "para", lines: paraLines });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return blocks;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderInline(
|
|
203
|
+
tokens: InlineToken[],
|
|
204
|
+
colors: { code: string; link: string },
|
|
205
|
+
): ReactNode[] {
|
|
206
|
+
return tokens.map((tok, idx) => {
|
|
207
|
+
if (tok.code) {
|
|
208
|
+
return (
|
|
209
|
+
<Text key={idx} color={colors.code}>
|
|
210
|
+
{` ${tok.text} `}
|
|
211
|
+
</Text>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (tok.link) {
|
|
215
|
+
return (
|
|
216
|
+
<Text key={idx} color={colors.link} underline>
|
|
217
|
+
{tok.text}
|
|
218
|
+
</Text>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return (
|
|
222
|
+
<Text key={idx} bold={tok.bold} italic={tok.italic}>
|
|
223
|
+
{tok.text}
|
|
224
|
+
</Text>
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface MarkdownBodyProps {
|
|
230
|
+
content: string;
|
|
231
|
+
baseColor?: string;
|
|
232
|
+
accentColor?: string;
|
|
233
|
+
dimColor?: string;
|
|
234
|
+
codeColor?: string;
|
|
235
|
+
width?: number;
|
|
236
|
+
paddingLeft?: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function bulletGlyph(marker: string, indent: number): string {
|
|
240
|
+
if (/^\d+\.$/.test(marker)) return `${marker} `;
|
|
241
|
+
if (indent >= 4) return "◦ ";
|
|
242
|
+
return "• ";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function MarkdownBodyInner({
|
|
246
|
+
content,
|
|
247
|
+
baseColor,
|
|
248
|
+
accentColor,
|
|
249
|
+
dimColor,
|
|
250
|
+
codeColor,
|
|
251
|
+
width,
|
|
252
|
+
paddingLeft = 0,
|
|
253
|
+
}: MarkdownBodyProps) {
|
|
254
|
+
const t = useTheme();
|
|
255
|
+
const blocks = useMemo(() => parseBlocks(content), [content]);
|
|
256
|
+
const text = baseColor ?? t.text;
|
|
257
|
+
const accent = accentColor ?? t.primaryLight;
|
|
258
|
+
const dim = dimColor ?? t.dim;
|
|
259
|
+
const code = codeColor ?? t.primaryDim;
|
|
260
|
+
const inlineColors = { code, link: accent };
|
|
261
|
+
const safeWidth = width !== undefined ? Math.max(1, Math.floor(width)) : undefined;
|
|
262
|
+
const ruleWidth = safeWidth !== undefined ? Math.max(4, safeWidth - paddingLeft - 1) : 24;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<Box flexDirection="column" flexShrink={1}>
|
|
266
|
+
{blocks.map((block, idx) => {
|
|
267
|
+
switch (block.kind) {
|
|
268
|
+
case "blank":
|
|
269
|
+
return <Box key={idx} height={1} />;
|
|
270
|
+
case "hr":
|
|
271
|
+
return (
|
|
272
|
+
<Box key={idx} paddingLeft={paddingLeft}>
|
|
273
|
+
<Text color={dim}>{"─".repeat(ruleWidth)}</Text>
|
|
274
|
+
</Box>
|
|
275
|
+
);
|
|
276
|
+
case "heading": {
|
|
277
|
+
const headColor = block.level <= 2 ? accent : text;
|
|
278
|
+
return (
|
|
279
|
+
<Box key={idx} paddingLeft={paddingLeft}>
|
|
280
|
+
<Text color={headColor} bold wrap="wrap">
|
|
281
|
+
{block.line}
|
|
282
|
+
</Text>
|
|
283
|
+
</Box>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
case "bullet": {
|
|
287
|
+
const glyph = bulletGlyph(block.marker, block.indent);
|
|
288
|
+
const indent = paddingLeft + Math.min(4, Math.floor(block.indent / 2));
|
|
289
|
+
return (
|
|
290
|
+
<Box key={idx} paddingLeft={indent}>
|
|
291
|
+
<Text color={accent}>{glyph}</Text>
|
|
292
|
+
<Text color={text} wrap="wrap">
|
|
293
|
+
{renderInline(tokenizeInline(block.line), inlineColors)}
|
|
294
|
+
</Text>
|
|
295
|
+
</Box>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
case "quote":
|
|
299
|
+
return (
|
|
300
|
+
<Box key={idx} paddingLeft={paddingLeft} flexDirection="column">
|
|
301
|
+
{block.lines.map((ln, j) => (
|
|
302
|
+
<Box key={j}>
|
|
303
|
+
<Text color={accent}>{"┃ "}</Text>
|
|
304
|
+
<Text color={dim} italic wrap="wrap">
|
|
305
|
+
{renderInline(tokenizeInline(ln), inlineColors)}
|
|
306
|
+
</Text>
|
|
307
|
+
</Box>
|
|
308
|
+
))}
|
|
309
|
+
</Box>
|
|
310
|
+
);
|
|
311
|
+
case "code":
|
|
312
|
+
return (
|
|
313
|
+
<Box
|
|
314
|
+
key={idx}
|
|
315
|
+
paddingLeft={paddingLeft}
|
|
316
|
+
flexDirection="column"
|
|
317
|
+
>
|
|
318
|
+
{block.lang ? (
|
|
319
|
+
<Text color={dim}>
|
|
320
|
+
[{block.lang.toLowerCase()}]
|
|
321
|
+
</Text>
|
|
322
|
+
) : null}
|
|
323
|
+
{block.lines.map((ln, j) => (
|
|
324
|
+
<Box key={j}>
|
|
325
|
+
<Text color={code}>{"│ "}</Text>
|
|
326
|
+
<Text color={text}>{ln}</Text>
|
|
327
|
+
</Box>
|
|
328
|
+
))}
|
|
329
|
+
</Box>
|
|
330
|
+
);
|
|
331
|
+
case "para":
|
|
332
|
+
return (
|
|
333
|
+
<Box key={idx} paddingLeft={paddingLeft} flexDirection="column">
|
|
334
|
+
{block.lines.map((ln, j) => (
|
|
335
|
+
<Text key={j} color={text} wrap="wrap">
|
|
336
|
+
{renderInline(tokenizeInline(ln), inlineColors)}
|
|
337
|
+
</Text>
|
|
338
|
+
))}
|
|
339
|
+
</Box>
|
|
340
|
+
);
|
|
341
|
+
default:
|
|
342
|
+
return <Fragment key={idx} />;
|
|
343
|
+
}
|
|
344
|
+
})}
|
|
345
|
+
</Box>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const MarkdownBody = memo(MarkdownBodyInner);
|
|
350
|
+
|
|
351
|
+
// Approximate row count for selectWindow budgeting. Each block contributes
|
|
352
|
+
// at least one row; paragraphs/quotes use line count plus naive width wrap.
|
|
353
|
+
export function estimateMarkdownRows(content: string, width: number): number {
|
|
354
|
+
const blocks = parseBlocks(content);
|
|
355
|
+
const safe = Math.max(1, Math.floor(width));
|
|
356
|
+
let rows = 0;
|
|
357
|
+
for (const block of blocks) {
|
|
358
|
+
if (block.kind === "blank") {
|
|
359
|
+
rows += 1;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (block.kind === "hr") {
|
|
363
|
+
rows += 1;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (block.kind === "heading" || block.kind === "bullet") {
|
|
367
|
+
rows += Math.max(1, Math.ceil(displayWidth(block.line) / safe));
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (block.kind === "quote" || block.kind === "para") {
|
|
371
|
+
for (const ln of block.lines) {
|
|
372
|
+
rows += Math.max(1, Math.ceil(displayWidth(ln) / safe));
|
|
373
|
+
}
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (block.kind === "code") {
|
|
377
|
+
rows += block.lines.length + (block.lang ? 1 : 0);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return Math.max(1, rows);
|
|
382
|
+
}
|