@ridit/lens 0.1.7 → 0.1.8

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,29 +1,415 @@
1
+ // StaticMessage.tsx
1
2
  import React from "react";
2
3
  import { Box, Text } from "ink";
3
- import { ACCENT } from "../../colors";
4
+ import { tokenize } from "sugar-high";
5
+ import {
6
+ ACCENT,
7
+ TOKEN_KEYWORD,
8
+ TOKEN_STRING,
9
+ TOKEN_NUMBER,
10
+ TOKEN_PROPERTY,
11
+ TOKEN_ENTITY,
12
+ TOKEN_TEXT,
13
+ TOKEN_MUTED,
14
+ TOKEN_COMMENT,
15
+ } from "../../colors";
4
16
  import type { Message } from "../../types/chat";
5
17
 
18
+ const T_IDENTIFIER = 0;
19
+ const T_KEYWORD = 1;
20
+ const T_STRING = 2;
21
+ const T_CLS_NUMBER = 3;
22
+ const T_PROPERTY = 4;
23
+ const T_ENTITY = 5;
24
+ const T_JSX_LITERAL = 6;
25
+ const T_SIGN = 7;
26
+ const T_COMMENT = 8;
27
+ const T_BREAK = 9;
28
+ const T_SPACE = 10;
29
+
30
+ const JS_LANGS = new Set([
31
+ "js",
32
+ "javascript",
33
+ "jsx",
34
+ "ts",
35
+ "typescript",
36
+ "tsx",
37
+ "mjs",
38
+ "cjs",
39
+ ]);
40
+
41
+ function tokenColor(type: number): string {
42
+ switch (type) {
43
+ case T_KEYWORD:
44
+ return TOKEN_KEYWORD;
45
+ case T_STRING:
46
+ return TOKEN_STRING;
47
+ case T_CLS_NUMBER:
48
+ return TOKEN_NUMBER;
49
+ case T_PROPERTY:
50
+ return TOKEN_PROPERTY;
51
+ case T_ENTITY:
52
+ return TOKEN_ENTITY;
53
+ case T_JSX_LITERAL:
54
+ return TOKEN_TEXT;
55
+ case T_SIGN:
56
+ return TOKEN_MUTED;
57
+ case T_COMMENT:
58
+ return TOKEN_COMMENT;
59
+ case T_IDENTIFIER:
60
+ return TOKEN_TEXT;
61
+ default:
62
+ return TOKEN_TEXT;
63
+ }
64
+ }
65
+
66
+ type SimpleToken = { color: string; text: string };
67
+
68
+ const PYTHON_KW = new Set([
69
+ "def",
70
+ "class",
71
+ "import",
72
+ "from",
73
+ "return",
74
+ "if",
75
+ "elif",
76
+ "else",
77
+ "for",
78
+ "while",
79
+ "in",
80
+ "not",
81
+ "and",
82
+ "or",
83
+ "is",
84
+ "None",
85
+ "True",
86
+ "False",
87
+ "try",
88
+ "except",
89
+ "finally",
90
+ "with",
91
+ "as",
92
+ "pass",
93
+ "break",
94
+ "continue",
95
+ "raise",
96
+ "yield",
97
+ "lambda",
98
+ "async",
99
+ "await",
100
+ "del",
101
+ "global",
102
+ "nonlocal",
103
+ "assert",
104
+ ]);
105
+ const RUST_KW = new Set([
106
+ "fn",
107
+ "let",
108
+ "mut",
109
+ "const",
110
+ "struct",
111
+ "enum",
112
+ "impl",
113
+ "trait",
114
+ "pub",
115
+ "use",
116
+ "mod",
117
+ "match",
118
+ "if",
119
+ "else",
120
+ "loop",
121
+ "while",
122
+ "for",
123
+ "in",
124
+ "return",
125
+ "self",
126
+ "Self",
127
+ "super",
128
+ "where",
129
+ "type",
130
+ "as",
131
+ "ref",
132
+ "move",
133
+ "unsafe",
134
+ "extern",
135
+ "dyn",
136
+ "async",
137
+ "await",
138
+ "true",
139
+ "false",
140
+ "Some",
141
+ "None",
142
+ "Ok",
143
+ "Err",
144
+ ]);
145
+ const GO_KW = new Set([
146
+ "func",
147
+ "var",
148
+ "const",
149
+ "type",
150
+ "struct",
151
+ "interface",
152
+ "package",
153
+ "import",
154
+ "return",
155
+ "if",
156
+ "else",
157
+ "for",
158
+ "range",
159
+ "switch",
160
+ "case",
161
+ "default",
162
+ "break",
163
+ "continue",
164
+ "goto",
165
+ "defer",
166
+ "go",
167
+ "chan",
168
+ "map",
169
+ "make",
170
+ "new",
171
+ "nil",
172
+ "true",
173
+ "false",
174
+ "error",
175
+ ]);
176
+ const SHELL_KW = new Set([
177
+ "if",
178
+ "then",
179
+ "else",
180
+ "elif",
181
+ "fi",
182
+ "for",
183
+ "do",
184
+ "done",
185
+ "while",
186
+ "case",
187
+ "esac",
188
+ "in",
189
+ "function",
190
+ "return",
191
+ "echo",
192
+ "export",
193
+ "local",
194
+ "source",
195
+ "exit",
196
+ ]);
197
+ const CSS_AT = /^@[\w-]+/;
198
+ const CSS_PROP = /^[\w-]+(?=\s*:)/;
199
+
200
+ function tokenizeGeneric(code: string, lang: string): SimpleToken[][] {
201
+ const keywords =
202
+ lang === "python" || lang === "py"
203
+ ? PYTHON_KW
204
+ : lang === "rust" || lang === "rs"
205
+ ? RUST_KW
206
+ : lang === "go"
207
+ ? GO_KW
208
+ : lang === "bash" ||
209
+ lang === "sh" ||
210
+ lang === "shell" ||
211
+ lang === "zsh"
212
+ ? SHELL_KW
213
+ : new Set<string>();
214
+
215
+ const lines = code.split("\n");
216
+ return lines.map((line) => {
217
+ const tokens: SimpleToken[] = [];
218
+ let i = 0;
219
+
220
+ const push = (color: string, text: string) => {
221
+ if (text) tokens.push({ color, text });
222
+ };
223
+
224
+ while (i < line.length) {
225
+ const rest = line.slice(i);
226
+
227
+ const commentPrefixes =
228
+ lang === "python" || lang === "py"
229
+ ? ["#"]
230
+ : lang === "bash" ||
231
+ lang === "sh" ||
232
+ lang === "shell" ||
233
+ lang === "zsh"
234
+ ? ["#"]
235
+ : lang === "css" || lang === "scss"
236
+ ? ["//", "/*"]
237
+ : lang === "html" || lang === "xml"
238
+ ? ["<!--"]
239
+ : lang === "sql"
240
+ ? ["--", "#"]
241
+ : ["//", "#"];
242
+
243
+ let matchedComment = false;
244
+ for (const prefix of commentPrefixes) {
245
+ if (rest.startsWith(prefix)) {
246
+ push(TOKEN_COMMENT, line.slice(i));
247
+ i = line.length;
248
+ matchedComment = true;
249
+ break;
250
+ }
251
+ }
252
+ if (matchedComment) continue;
253
+
254
+ if (line[i] === '"' || line[i] === "'" || line[i] === "`") {
255
+ const quote = line[i]!;
256
+ let j = i + 1;
257
+ while (j < line.length) {
258
+ if (line[j] === "\\") {
259
+ j += 2;
260
+ continue;
261
+ }
262
+ if (line[j] === quote) {
263
+ j++;
264
+ break;
265
+ }
266
+ j++;
267
+ }
268
+ push(TOKEN_STRING, line.slice(i, j));
269
+ i = j;
270
+ continue;
271
+ }
272
+
273
+ const numMatch = rest.match(/^-?\d+\.?\d*/);
274
+ if (numMatch && (i === 0 || !/\w/.test(line[i - 1] ?? ""))) {
275
+ push(TOKEN_NUMBER, numMatch[0]);
276
+ i += numMatch[0].length;
277
+ continue;
278
+ }
279
+
280
+ if (lang === "css" || lang === "scss") {
281
+ const atMatch = rest.match(CSS_AT);
282
+ if (atMatch) {
283
+ push(TOKEN_KEYWORD, atMatch[0]);
284
+ i += atMatch[0].length;
285
+ continue;
286
+ }
287
+ const propMatch = rest.match(CSS_PROP);
288
+ if (propMatch) {
289
+ push(TOKEN_PROPERTY, propMatch[0]);
290
+ i += propMatch[0].length;
291
+ continue;
292
+ }
293
+ }
294
+
295
+ if ((lang === "html" || lang === "xml") && line[i] === "<") {
296
+ const tagMatch = rest.match(/^<\/?[\w:-]+/);
297
+ if (tagMatch) {
298
+ push(TOKEN_ENTITY, tagMatch[0]);
299
+ i += tagMatch[0].length;
300
+ continue;
301
+ }
302
+ }
303
+
304
+ const wordMatch = rest.match(/^[a-zA-Z_$][\w$]*/);
305
+ if (wordMatch) {
306
+ const word = wordMatch[0];
307
+ push(keywords.has(word) ? TOKEN_KEYWORD : TOKEN_TEXT, word);
308
+ i += word.length;
309
+ continue;
310
+ }
311
+
312
+ const opMatch = rest.match(/^[^\w\s"'`]+/);
313
+ if (opMatch) {
314
+ push(TOKEN_MUTED, opMatch[0]);
315
+ i += opMatch[0].length;
316
+ continue;
317
+ }
318
+
319
+ push(TOKEN_TEXT, line[i]!);
320
+ i++;
321
+ }
322
+
323
+ return tokens;
324
+ });
325
+ }
326
+
327
+ function HighlightedLine({ tokens }: { tokens: SimpleToken[] }) {
328
+ return (
329
+ <Text>
330
+ {" "}
331
+ {tokens.map((t, i) => (
332
+ <Text key={i} color={t.color as any}>
333
+ {t.text}
334
+ </Text>
335
+ ))}
336
+ </Text>
337
+ );
338
+ }
339
+
340
+ function CodeBlock({ lang, code }: { lang: string; code: string }) {
341
+ const normalizedLang = lang.toLowerCase().trim();
342
+
343
+ let lines: SimpleToken[][];
344
+
345
+ if (JS_LANGS.has(normalizedLang)) {
346
+ const tokens = tokenize(code);
347
+ const lineAccum: SimpleToken[][] = [[]];
348
+
349
+ for (const [type, value] of tokens) {
350
+ if (type === T_BREAK) {
351
+ lineAccum.push([]);
352
+ } else if (type !== T_SPACE) {
353
+ lineAccum[lineAccum.length - 1]!.push({
354
+ color: tokenColor(type),
355
+ text: value,
356
+ });
357
+ } else {
358
+ lineAccum[lineAccum.length - 1]!.push({
359
+ color: TOKEN_TEXT,
360
+ text: value,
361
+ });
362
+ }
363
+ }
364
+ lines = lineAccum;
365
+ } else if (normalizedLang) {
366
+ lines = tokenizeGeneric(code, normalizedLang);
367
+ } else {
368
+ lines = code.split("\n").map((l) => [{ color: TOKEN_TEXT, text: l }]);
369
+ }
370
+
371
+ return (
372
+ <Box flexDirection="column" marginY={1} marginLeft={2}>
373
+ {normalizedLang ? (
374
+ <Text color={TOKEN_MUTED} dimColor>
375
+ {normalizedLang}
376
+ </Text>
377
+ ) : null}
378
+ {lines.map((lineTokens, i) => (
379
+ <HighlightedLine key={i} tokens={lineTokens} />
380
+ ))}
381
+ </Box>
382
+ );
383
+ }
384
+
6
385
  function InlineText({ text }: { text: string }) {
7
- const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
386
+ const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
8
387
  return (
9
388
  <>
10
389
  {parts.map((part, i) => {
11
- if (part.startsWith("`") && part.endsWith("`")) {
390
+ if (part.startsWith("**") && part.endsWith("**")) {
12
391
  return (
13
- <Text key={i} color={ACCENT}>
392
+ <Text key={i} bold color={TOKEN_TEXT}>
393
+ {part.slice(2, -2)}
394
+ </Text>
395
+ );
396
+ }
397
+ if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
398
+ return (
399
+ <Text key={i} italic color={TOKEN_TEXT}>
14
400
  {part.slice(1, -1)}
15
401
  </Text>
16
402
  );
17
403
  }
18
- if (part.startsWith("**") && part.endsWith("**")) {
404
+ if (part.startsWith("`") && part.endsWith("`")) {
19
405
  return (
20
- <Text key={i} bold color="white">
21
- {part.slice(2, -2)}
406
+ <Text key={i} color={ACCENT}>
407
+ {part.slice(1, -1)}
22
408
  </Text>
23
409
  );
24
410
  }
25
411
  return (
26
- <Text key={i} color="white">
412
+ <Text key={i} color={TOKEN_TEXT}>
27
413
  {part}
28
414
  </Text>
29
415
  );
@@ -32,68 +418,177 @@ function InlineText({ text }: { text: string }) {
32
418
  );
33
419
  }
34
420
 
35
- function CodeBlock({ lang, code }: { lang: string; code: string }) {
36
- return (
37
- <Box flexDirection="column" marginY={1} marginLeft={2}>
38
- {lang && <Text color="gray">{lang}</Text>}
39
- {code.split("\n").map((line, i) => (
40
- <Text key={i} color={ACCENT}>
41
- {" "}
42
- {line}
421
+ function Heading({ level, text }: { level: 1 | 2 | 3; text: string }) {
422
+ if (level === 1) {
423
+ return (
424
+ <Box marginTop={1}>
425
+ <Text color={ACCENT} bold underline>
426
+ {text}
43
427
  </Text>
44
- ))}
428
+ </Box>
429
+ );
430
+ }
431
+ if (level === 2) {
432
+ return (
433
+ <Box marginTop={1}>
434
+ <Text color={ACCENT} bold>
435
+ {text}
436
+ </Text>
437
+ </Box>
438
+ );
439
+ }
440
+ return (
441
+ <Box marginTop={1}>
442
+ <Text color={TOKEN_TEXT} bold>
443
+ {text}
444
+ </Text>
45
445
  </Box>
46
446
  );
47
447
  }
48
448
 
49
- function MessageBody({ content }: { content: string }) {
449
+ function BulletItem({ text }: { text: string }) {
450
+ return (
451
+ <Box gap={1}>
452
+ <Text color={ACCENT}>{"*"}</Text>
453
+ <Box flexShrink={1}>
454
+ <InlineText text={text} />
455
+ </Box>
456
+ </Box>
457
+ );
458
+ }
459
+
460
+ function NumberedItem({ num, text }: { num: string; text: string }) {
461
+ return (
462
+ <Box gap={1}>
463
+ <Text color={TOKEN_MUTED}>{num}.</Text>
464
+ <Box flexShrink={1}>
465
+ <InlineText text={text} />
466
+ </Box>
467
+ </Box>
468
+ );
469
+ }
470
+
471
+ function BlockQuote({ text }: { text: string }) {
472
+ return (
473
+ <Box gap={1} marginLeft={1}>
474
+ <Text color={TOKEN_MUTED}>{"│"}</Text>
475
+ <Text color={TOKEN_MUTED} dimColor>
476
+ {text}
477
+ </Text>
478
+ </Box>
479
+ );
480
+ }
481
+
482
+ type Block =
483
+ | { type: "heading"; level: 1 | 2 | 3; text: string }
484
+ | { type: "code"; lang: string; code: string }
485
+ | { type: "bullet"; text: string }
486
+ | { type: "numbered"; num: string; text: string }
487
+ | { type: "blockquote"; text: string }
488
+ | { type: "hr" }
489
+ | { type: "paragraph"; text: string };
490
+
491
+ function parseBlocks(content: string): Block[] {
492
+ const blocks: Block[] = [];
50
493
  const segments = content.split(/(```[\s\S]*?```)/g);
51
494
 
495
+ for (const seg of segments) {
496
+ if (seg.startsWith("```")) {
497
+ const lines = seg.slice(3).split("\n");
498
+ const lang = lines[0]?.trim() ?? "";
499
+ const code = lines
500
+ .slice(1)
501
+ .join("\n")
502
+ .replace(/```\s*$/, "")
503
+ .trimEnd();
504
+ blocks.push({ type: "code", lang, code });
505
+ continue;
506
+ }
507
+
508
+ for (const line of seg.split("\n")) {
509
+ const trimmed = line.trim();
510
+ if (!trimmed) continue;
511
+
512
+ const h3 = trimmed.match(/^### (.+)$/);
513
+ const h2 = trimmed.match(/^## (.+)$/);
514
+ const h1 = trimmed.match(/^# (.+)$/);
515
+ if (h3) {
516
+ blocks.push({ type: "heading", level: 3, text: h3[1]! });
517
+ continue;
518
+ }
519
+ if (h2) {
520
+ blocks.push({ type: "heading", level: 2, text: h2[1]! });
521
+ continue;
522
+ }
523
+ if (h1) {
524
+ blocks.push({ type: "heading", level: 1, text: h1[1]! });
525
+ continue;
526
+ }
527
+
528
+ if (/^[-*_]{3,}$/.test(trimmed)) {
529
+ blocks.push({ type: "hr" });
530
+ continue;
531
+ }
532
+
533
+ if (trimmed.startsWith("> ")) {
534
+ blocks.push({ type: "blockquote", text: trimmed.slice(2).trim() });
535
+ continue;
536
+ }
537
+
538
+ if (/^[-*•]\s/.test(trimmed)) {
539
+ blocks.push({ type: "bullet", text: trimmed.slice(2).trim() });
540
+ continue;
541
+ }
542
+
543
+ const numMatch = trimmed.match(/^(\d+)\.\s(.+)/);
544
+ if (numMatch) {
545
+ blocks.push({
546
+ type: "numbered",
547
+ num: numMatch[1]!,
548
+ text: numMatch[2]!,
549
+ });
550
+ continue;
551
+ }
552
+
553
+ blocks.push({ type: "paragraph", text: trimmed });
554
+ }
555
+ }
556
+
557
+ return blocks;
558
+ }
559
+
560
+ function MessageBody({ content }: { content: string }) {
561
+ const blocks = parseBlocks(content);
562
+
52
563
  return (
53
564
  <Box flexDirection="column">
54
- {segments.map((seg, si) => {
55
- if (seg.startsWith("```")) {
56
- const lines = seg.slice(3).split("\n");
57
- const lang = lines[0]?.trim() ?? "";
58
- const code = lines
59
- .slice(1)
60
- .join("\n")
61
- .replace(/```\s*$/, "")
62
- .trimEnd();
63
- return <CodeBlock key={si} lang={lang} code={code} />;
565
+ {blocks.map((block, i) => {
566
+ switch (block.type) {
567
+ case "heading":
568
+ return <Heading key={i} level={block.level} text={block.text} />;
569
+ case "code":
570
+ return <CodeBlock key={i} lang={block.lang} code={block.code} />;
571
+ case "bullet":
572
+ return <BulletItem key={i} text={block.text} />;
573
+ case "numbered":
574
+ return <NumberedItem key={i} num={block.num} text={block.text} />;
575
+ case "blockquote":
576
+ return <BlockQuote key={i} text={block.text} />;
577
+ case "hr":
578
+ return (
579
+ <Box key={i} marginY={1}>
580
+ <Text color={TOKEN_MUTED} dimColor>
581
+ {"─".repeat(40)}
582
+ </Text>
583
+ </Box>
584
+ );
585
+ case "paragraph":
586
+ return (
587
+ <Box key={i}>
588
+ <InlineText text={block.text} />
589
+ </Box>
590
+ );
64
591
  }
65
-
66
- const lines = seg.split("\n").filter((l) => l.trim() !== "");
67
- return (
68
- <Box key={si} flexDirection="column">
69
- {lines.map((line, li) => {
70
- if (line.match(/^[-*•]\s/)) {
71
- return (
72
- <Box key={li} gap={1}>
73
- <Text color={ACCENT}>*</Text>
74
- <InlineText text={line.slice(2).trim()} />
75
- </Box>
76
- );
77
- }
78
-
79
- if (line.match(/^\d+\.\s/)) {
80
- const num = line.match(/^(\d+)\.\s/)![1];
81
- return (
82
- <Box key={li} gap={1}>
83
- <Text color="gray">{num}.</Text>
84
- <InlineText text={line.replace(/^\d+\.\s/, "").trim()} />
85
- </Box>
86
- );
87
- }
88
-
89
- return (
90
- <Box key={li}>
91
- <InlineText text={line} />
92
- </Box>
93
- );
94
- })}
95
- </Box>
96
- );
97
592
  })}
98
593
  </Box>
99
594
  );
@@ -103,8 +598,8 @@ export function StaticMessage({ msg }: { msg: Message }) {
103
598
  if (msg.role === "user") {
104
599
  return (
105
600
  <Box marginBottom={1} gap={1}>
106
- <Text color="gray">{">"}</Text>
107
- <Text color="white" bold>
601
+ <Text color={TOKEN_MUTED}>{">"}</Text>
602
+ <Text color={TOKEN_TEXT} bold>
108
603
  {msg.content}
109
604
  </Text>
110
605
  </Box>
@@ -137,14 +632,17 @@ export function StaticMessage({ msg }: { msg: Message }) {
137
632
  <Box flexDirection="column" marginBottom={1}>
138
633
  <Box gap={1}>
139
634
  <Text color={msg.approved ? ACCENT : "red"}>{icon}</Text>
140
- <Text color={msg.approved ? "gray" : "red"} dimColor={!msg.approved}>
635
+ <Text
636
+ color={msg.approved ? TOKEN_MUTED : "red"}
637
+ dimColor={!msg.approved}
638
+ >
141
639
  {label}
142
640
  </Text>
143
641
  {!msg.approved && <Text color="red">denied</Text>}
144
642
  </Box>
145
643
  {msg.approved && msg.result && (
146
644
  <Box marginLeft={2}>
147
- <Text color="gray">
645
+ <Text color={TOKEN_MUTED}>
148
646
  {msg.result.split("\n")[0]?.slice(0, 120)}
149
647
  {(msg.result.split("\n")[0]?.length ?? 0) > 120 ? "…" : ""}
150
648
  </Text>
@@ -162,10 +660,13 @@ export function StaticMessage({ msg }: { msg: Message }) {
162
660
  <MessageBody content={msg.content} />
163
661
  </Box>
164
662
  <Box marginLeft={2} gap={1}>
165
- <Text color={msg.applied ? "green" : "gray"}>
663
+ <Text color={msg.applied ? "green" : TOKEN_MUTED}>
166
664
  {msg.applied ? "✓" : "·"}
167
665
  </Text>
168
- <Text color={msg.applied ? "green" : "gray"} dimColor={!msg.applied}>
666
+ <Text
667
+ color={msg.applied ? "green" : TOKEN_MUTED}
668
+ dimColor={!msg.applied}
669
+ >
169
670
  {msg.applied ? "changes applied" : "changes skipped"}
170
671
  </Text>
171
672
  </Box>