hoomanjs 1.13.0 → 1.14.0
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/package.json +4 -1
- package/src/chat/app.tsx +1 -1
- package/src/chat/components/ChatMessage.tsx +13 -10
- package/src/chat/components/Composer.tsx +1 -1
- package/src/chat/components/PromptInput.tsx +1 -1
- package/src/chat/components/QueuedPrompts.tsx +1 -1
- package/src/chat/components/markdown/BlockRenderer.tsx +207 -0
- package/src/chat/components/markdown/CodeBlock.tsx +100 -0
- package/src/chat/components/markdown/InlineRenderer.tsx +122 -0
- package/src/chat/components/markdown/MarkdownMessage.tsx +35 -0
- package/src/chat/components/markdown/MarkdownTable.tsx +23 -0
- package/src/chat/components/markdown/hooks/useMarkdownTableLayout.ts +242 -0
- package/src/chat/components/markdown/hooks/useMarkdownTokens.ts +46 -0
- package/src/chat/components/markdown/lexer.ts +100 -0
- package/src/chat/components/prompt-input/hooks/usePromptInputController.ts +4 -0
- package/src/chat/components/shared.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoomanjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Hackable Bun-powered AI agent toolkit for building local CLI, ACP, MCP, and channel-driven workflows.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Vaibhav Pandey",
|
|
@@ -61,17 +61,20 @@
|
|
|
61
61
|
"@mozilla/readability": "^0.6.0",
|
|
62
62
|
"@strands-agents/sdk": "^1.0.0-rc.3",
|
|
63
63
|
"chromadb": "^3.4.3",
|
|
64
|
+
"cli-highlight": "^2.1.11",
|
|
64
65
|
"cli-spinners": "^3.4.0",
|
|
65
66
|
"commander": "^14.0.3",
|
|
66
67
|
"fastq": "^1.20.1",
|
|
67
68
|
"gray-matter": "^4.0.3",
|
|
68
69
|
"handlebars": "^4.7.9",
|
|
69
70
|
"ink": "^7.0.0",
|
|
71
|
+
"ink-ansi": "^1.0.0",
|
|
70
72
|
"ink-select-input": "^6.2.0",
|
|
71
73
|
"ink-text-input": "^6.0.0",
|
|
72
74
|
"jsdom": "^29.0.2",
|
|
73
75
|
"lodash": "^4.18.1",
|
|
74
76
|
"luxon": "^3.7.2",
|
|
77
|
+
"marked": "^18.0.2",
|
|
75
78
|
"ollama": "^0.6.3",
|
|
76
79
|
"openai": "^6.34.0",
|
|
77
80
|
"react": "^19.2.5",
|
package/src/chat/app.tsx
CHANGED
|
@@ -30,7 +30,7 @@ import { Transcript } from "./components/Transcript.tsx";
|
|
|
30
30
|
import type { ApprovalRequest, ChatLine } from "./types.ts";
|
|
31
31
|
import { getTodoViewState, type TodoViewState } from "../core/tools/todo.ts";
|
|
32
32
|
import { attachmentPathsToPromptBlocks } from "../core/utils/attachments.ts";
|
|
33
|
-
import type { PromptSubmission } from "./components/prompt-input/usePromptInputController.ts";
|
|
33
|
+
import type { PromptSubmission } from "./components/prompt-input/hooks/usePromptInputController.ts";
|
|
34
34
|
|
|
35
35
|
type ChatAppProps = {
|
|
36
36
|
agent: Agent;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import type { ChatLine } from "../types.ts";
|
|
3
3
|
import { lineColor } from "./shared.ts";
|
|
4
|
+
import { MarkdownMessage } from "./markdown/MarkdownMessage.tsx";
|
|
4
5
|
import { ReasoningStrip } from "./ReasoningStrip.tsx";
|
|
5
6
|
import { ThinkingStatus } from "./ThinkingStatus.tsx";
|
|
6
7
|
|
|
@@ -17,7 +18,9 @@ export function ChatMessage({ line, liveReasoning = "" }: ChatMessageProps) {
|
|
|
17
18
|
? "Assistant"
|
|
18
19
|
: (line.title ?? "System");
|
|
19
20
|
const isPendingAssistant = line.role === "assistant" && !line.done;
|
|
20
|
-
const
|
|
21
|
+
const rawText =
|
|
22
|
+
line.role === "assistant" ? line.content : line.content.trim();
|
|
23
|
+
const text = rawText || (line.done ? "(empty)" : "");
|
|
21
24
|
const shouldShowBody = Boolean(text) || !isPendingAssistant;
|
|
22
25
|
|
|
23
26
|
return (
|
|
@@ -32,15 +35,15 @@ export function ChatMessage({ line, liveReasoning = "" }: ChatMessageProps) {
|
|
|
32
35
|
<ReasoningStrip text={liveReasoning} maxVisibleLines={2} />
|
|
33
36
|
) : null}
|
|
34
37
|
{shouldShowBody ? (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
line.role === "assistant" ? (
|
|
39
|
+
<MarkdownMessage streaming={isPendingAssistant}>
|
|
40
|
+
{text}
|
|
41
|
+
</MarkdownMessage>
|
|
42
|
+
) : (
|
|
43
|
+
<Text color={line.role === "user" ? undefined : lineColor(line)}>
|
|
44
|
+
{text}
|
|
45
|
+
</Text>
|
|
46
|
+
)
|
|
44
47
|
) : null}
|
|
45
48
|
</Box>
|
|
46
49
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { PromptInput } from "./PromptInput.tsx";
|
|
3
|
-
import type { PromptSubmission } from "./prompt-input/usePromptInputController.ts";
|
|
3
|
+
import type { PromptSubmission } from "./prompt-input/hooks/usePromptInputController.ts";
|
|
4
4
|
|
|
5
5
|
type ComposerProps = {
|
|
6
6
|
input: string;
|
|
@@ -4,7 +4,7 @@ import { splitLineAtCursor } from "./prompt-input/render.ts";
|
|
|
4
4
|
import {
|
|
5
5
|
usePromptInputController,
|
|
6
6
|
type PromptSubmission,
|
|
7
|
-
} from "./prompt-input/usePromptInputController.ts";
|
|
7
|
+
} from "./prompt-input/hooks/usePromptInputController.ts";
|
|
8
8
|
|
|
9
9
|
export type PromptInputProps = {
|
|
10
10
|
value: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text, useStdout } from "ink";
|
|
3
|
-
import type { PromptSubmission } from "./prompt-input/usePromptInputController.ts";
|
|
3
|
+
import type { PromptSubmission } from "./prompt-input/hooks/usePromptInputController.ts";
|
|
4
4
|
|
|
5
5
|
type QueuedPromptsProps = {
|
|
6
6
|
prompts: readonly { id: string; prompt: PromptSubmission }[];
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
3
|
+
import type { Token, Tokens } from "marked";
|
|
4
|
+
import { CodeBlock } from "./CodeBlock.tsx";
|
|
5
|
+
import { inlineToPlainText, renderInlineTokens } from "./InlineRenderer.tsx";
|
|
6
|
+
import { MarkdownTable } from "./MarkdownTable.tsx";
|
|
7
|
+
|
|
8
|
+
type BlockRendererProps = {
|
|
9
|
+
tokens: Token[];
|
|
10
|
+
streaming?: boolean;
|
|
11
|
+
depth?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function blockToPlainText(token: Token): string {
|
|
15
|
+
switch (token.type) {
|
|
16
|
+
case "paragraph":
|
|
17
|
+
return inlineToPlainText(token.tokens);
|
|
18
|
+
case "text":
|
|
19
|
+
return token.text ?? "";
|
|
20
|
+
case "code":
|
|
21
|
+
return token.text ?? "";
|
|
22
|
+
case "heading":
|
|
23
|
+
return inlineToPlainText(token.tokens);
|
|
24
|
+
case "blockquote":
|
|
25
|
+
return (token.tokens ?? [])
|
|
26
|
+
.map((child) => blockToPlainText(child))
|
|
27
|
+
.join("\n");
|
|
28
|
+
case "list":
|
|
29
|
+
return token.items
|
|
30
|
+
.map((item: Tokens.ListItem) =>
|
|
31
|
+
(item.tokens ?? [])
|
|
32
|
+
.map((child: Token) => blockToPlainText(child))
|
|
33
|
+
.join(" "),
|
|
34
|
+
)
|
|
35
|
+
.join("\n");
|
|
36
|
+
default:
|
|
37
|
+
return token.raw ?? "";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderBlock(
|
|
42
|
+
token: Token,
|
|
43
|
+
key: string,
|
|
44
|
+
depth: number,
|
|
45
|
+
columns: number,
|
|
46
|
+
streaming: boolean,
|
|
47
|
+
): React.ReactNode | null {
|
|
48
|
+
switch (token.type) {
|
|
49
|
+
case "paragraph":
|
|
50
|
+
return (
|
|
51
|
+
<Box key={key} marginBottom={1}>
|
|
52
|
+
<Text wrap="wrap">
|
|
53
|
+
{renderInlineTokens(token.tokens, { keyPrefix: key })}
|
|
54
|
+
</Text>
|
|
55
|
+
</Box>
|
|
56
|
+
);
|
|
57
|
+
case "heading": {
|
|
58
|
+
const headingColor = token.depth <= 2 ? "cyan" : "white";
|
|
59
|
+
return (
|
|
60
|
+
<Box key={key} marginBottom={1}>
|
|
61
|
+
<Text bold color={headingColor} wrap="wrap">
|
|
62
|
+
{renderInlineTokens(token.tokens, { keyPrefix: key })}
|
|
63
|
+
</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
case "code":
|
|
68
|
+
return (
|
|
69
|
+
<CodeBlock
|
|
70
|
+
key={key}
|
|
71
|
+
code={token.text ?? ""}
|
|
72
|
+
language={token.lang}
|
|
73
|
+
streaming={streaming}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
case "blockquote": {
|
|
77
|
+
const lines = (token.tokens ?? [])
|
|
78
|
+
.map((child) => blockToPlainText(child))
|
|
79
|
+
.join("\n")
|
|
80
|
+
.split("\n");
|
|
81
|
+
return (
|
|
82
|
+
<Box key={key} flexDirection="column" marginBottom={1}>
|
|
83
|
+
{lines.map((line, index) => (
|
|
84
|
+
<Text key={`${key}-${index}`} color="gray" italic>
|
|
85
|
+
{`│ ${line || " "}`}
|
|
86
|
+
</Text>
|
|
87
|
+
))}
|
|
88
|
+
</Box>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
case "list":
|
|
92
|
+
return (
|
|
93
|
+
<Box key={key} flexDirection="column" marginBottom={1}>
|
|
94
|
+
{token.items.map((item: Tokens.ListItem, index: number) => {
|
|
95
|
+
const marker = token.ordered
|
|
96
|
+
? `${(token.start ?? 1) + index}.`
|
|
97
|
+
: "-";
|
|
98
|
+
const checkbox =
|
|
99
|
+
item.task === true ? (item.checked ? "[x] " : "[ ] ") : "";
|
|
100
|
+
const itemTokens = item.tokens ?? [];
|
|
101
|
+
const [head, ...tail] = itemTokens as Token[];
|
|
102
|
+
const isInlineHead =
|
|
103
|
+
head?.type === "text" || head?.type === "paragraph";
|
|
104
|
+
const headNode =
|
|
105
|
+
head && isInlineHead ? (
|
|
106
|
+
<Text wrap="wrap">
|
|
107
|
+
{head.type === "paragraph"
|
|
108
|
+
? renderInlineTokens(head.tokens, {
|
|
109
|
+
keyPrefix: `${key}-item-${index}-paragraph`,
|
|
110
|
+
})
|
|
111
|
+
: (head.text ??
|
|
112
|
+
renderInlineTokens(head.tokens, {
|
|
113
|
+
keyPrefix: `${key}-item-${index}-text`,
|
|
114
|
+
}))}
|
|
115
|
+
</Text>
|
|
116
|
+
) : head ? (
|
|
117
|
+
renderBlock(
|
|
118
|
+
head,
|
|
119
|
+
`${key}-item-${index}-head`,
|
|
120
|
+
depth + 1,
|
|
121
|
+
columns,
|
|
122
|
+
streaming,
|
|
123
|
+
)
|
|
124
|
+
) : (
|
|
125
|
+
<Text> </Text>
|
|
126
|
+
);
|
|
127
|
+
return (
|
|
128
|
+
<Box key={`${key}-item-${index}`} flexDirection="column">
|
|
129
|
+
<Box flexDirection="row">
|
|
130
|
+
<Text>{`${" ".repeat(depth)}${marker} ${checkbox}`}</Text>
|
|
131
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
132
|
+
{headNode}
|
|
133
|
+
</Box>
|
|
134
|
+
</Box>
|
|
135
|
+
{tail.length > 0 ? (
|
|
136
|
+
<Box
|
|
137
|
+
marginLeft={Math.max(2, depth * 2 + 4)}
|
|
138
|
+
flexDirection="column"
|
|
139
|
+
>
|
|
140
|
+
{tail.map((nestedToken: Token, nestedIndex: number) =>
|
|
141
|
+
renderBlock(
|
|
142
|
+
nestedToken,
|
|
143
|
+
`${key}-item-${index}-tail-${nestedIndex}`,
|
|
144
|
+
depth + 1,
|
|
145
|
+
columns,
|
|
146
|
+
streaming,
|
|
147
|
+
),
|
|
148
|
+
)}
|
|
149
|
+
</Box>
|
|
150
|
+
) : null}
|
|
151
|
+
</Box>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</Box>
|
|
155
|
+
);
|
|
156
|
+
case "table":
|
|
157
|
+
return <MarkdownTable key={key} token={token as Tokens.Table} />;
|
|
158
|
+
case "hr":
|
|
159
|
+
return (
|
|
160
|
+
<Box key={key} marginBottom={1}>
|
|
161
|
+
<Text color="gray">
|
|
162
|
+
{"─".repeat(Math.max(8, Math.min(columns - 4, 48)))}
|
|
163
|
+
</Text>
|
|
164
|
+
</Box>
|
|
165
|
+
);
|
|
166
|
+
case "space":
|
|
167
|
+
return null;
|
|
168
|
+
case "text":
|
|
169
|
+
case "escape":
|
|
170
|
+
return (
|
|
171
|
+
<Box key={key} marginBottom={1}>
|
|
172
|
+
<Text wrap="wrap">{token.text ?? token.raw ?? ""}</Text>
|
|
173
|
+
</Box>
|
|
174
|
+
);
|
|
175
|
+
case "html":
|
|
176
|
+
case "def":
|
|
177
|
+
case "del":
|
|
178
|
+
return null;
|
|
179
|
+
default:
|
|
180
|
+
return (
|
|
181
|
+
<Box key={key} marginBottom={1}>
|
|
182
|
+
<Text wrap="wrap">{token.raw ?? ""}</Text>
|
|
183
|
+
</Box>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function BlockRenderer({
|
|
189
|
+
tokens,
|
|
190
|
+
streaming = false,
|
|
191
|
+
depth = 0,
|
|
192
|
+
}: BlockRendererProps) {
|
|
193
|
+
const { columns } = useWindowSize();
|
|
194
|
+
return (
|
|
195
|
+
<Box flexDirection="column">
|
|
196
|
+
{tokens.map((token, index) =>
|
|
197
|
+
renderBlock(
|
|
198
|
+
token,
|
|
199
|
+
`block-${depth}-${index}`,
|
|
200
|
+
depth,
|
|
201
|
+
columns,
|
|
202
|
+
streaming,
|
|
203
|
+
),
|
|
204
|
+
)}
|
|
205
|
+
</Box>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { Box, Text } from "ink";
|
|
5
|
+
import { highlight, supportsLanguage } from "cli-highlight";
|
|
6
|
+
|
|
7
|
+
type CodeBlockProps = {
|
|
8
|
+
code: string;
|
|
9
|
+
language?: string;
|
|
10
|
+
streaming?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type InkAnsiComponent = React.ComponentType<{
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
const localRequire = createRequire(import.meta.url);
|
|
18
|
+
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
19
|
+
let cachedInkAnsi: InkAnsiComponent | null | undefined;
|
|
20
|
+
|
|
21
|
+
function stripAnsi(value: string): string {
|
|
22
|
+
return value.replace(ANSI_RE, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getInkAnsi(): InkAnsiComponent | null {
|
|
26
|
+
if (cachedInkAnsi !== undefined) {
|
|
27
|
+
return cachedInkAnsi;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const moduleValue = localRequire("ink-ansi") as
|
|
31
|
+
| InkAnsiComponent
|
|
32
|
+
| { default?: InkAnsiComponent };
|
|
33
|
+
cachedInkAnsi =
|
|
34
|
+
typeof moduleValue === "function"
|
|
35
|
+
? moduleValue
|
|
36
|
+
: (moduleValue.default ?? null);
|
|
37
|
+
} catch {
|
|
38
|
+
cachedInkAnsi = null;
|
|
39
|
+
}
|
|
40
|
+
return cachedInkAnsi;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderCodeLines(lines: string[], color?: string) {
|
|
44
|
+
return lines.map((line, index) => (
|
|
45
|
+
<Text key={index} color={color}>
|
|
46
|
+
{line || " "}
|
|
47
|
+
</Text>
|
|
48
|
+
));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function CodeBlock({
|
|
52
|
+
code,
|
|
53
|
+
language,
|
|
54
|
+
streaming = false,
|
|
55
|
+
}: CodeBlockProps) {
|
|
56
|
+
const trimmedLanguage = language?.trim() || undefined;
|
|
57
|
+
const normalizedCode = useMemo(() => code.replace(/\r\n/g, "\n"), [code]);
|
|
58
|
+
const plainLines = useMemo(
|
|
59
|
+
() => normalizedCode.split("\n"),
|
|
60
|
+
[normalizedCode],
|
|
61
|
+
);
|
|
62
|
+
const highlightedLines = useMemo(() => {
|
|
63
|
+
if (streaming || !trimmedLanguage || !supportsLanguage(trimmedLanguage)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return highlight(normalizedCode, {
|
|
68
|
+
language: trimmedLanguage,
|
|
69
|
+
ignoreIllegals: true,
|
|
70
|
+
}).split("\n");
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}, [normalizedCode, streaming, trimmedLanguage]);
|
|
75
|
+
|
|
76
|
+
if (highlightedLines) {
|
|
77
|
+
const InkAnsiText = getInkAnsi();
|
|
78
|
+
return (
|
|
79
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
80
|
+
<Text color="gray">{`\`\`\`${trimmedLanguage}`}</Text>
|
|
81
|
+
{highlightedLines.map((line, index) =>
|
|
82
|
+
InkAnsiText ? (
|
|
83
|
+
<InkAnsiText key={index}>{line || " "}</InkAnsiText>
|
|
84
|
+
) : (
|
|
85
|
+
<Text key={index}>{stripAnsi(line) || " "}</Text>
|
|
86
|
+
),
|
|
87
|
+
)}
|
|
88
|
+
<Text color="gray">```</Text>
|
|
89
|
+
</Box>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
95
|
+
<Text color="gray">{`\`\`\`${trimmedLanguage ?? ""}`}</Text>
|
|
96
|
+
{renderCodeLines(plainLines, "white")}
|
|
97
|
+
<Text color="gray">```</Text>
|
|
98
|
+
</Box>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import type { Token } from "marked";
|
|
4
|
+
|
|
5
|
+
function plainFromToken(token: Token): string {
|
|
6
|
+
switch (token.type) {
|
|
7
|
+
case "text":
|
|
8
|
+
return token.text ?? "";
|
|
9
|
+
case "codespan":
|
|
10
|
+
return token.text ?? "";
|
|
11
|
+
case "escape":
|
|
12
|
+
return token.text ?? "";
|
|
13
|
+
case "link": {
|
|
14
|
+
const label = inlineToPlainText(token.tokens);
|
|
15
|
+
return label || token.href || "";
|
|
16
|
+
}
|
|
17
|
+
case "strong":
|
|
18
|
+
case "em":
|
|
19
|
+
case "del":
|
|
20
|
+
return inlineToPlainText(token.tokens);
|
|
21
|
+
case "image":
|
|
22
|
+
return token.text || token.href || "";
|
|
23
|
+
case "br":
|
|
24
|
+
return "\n";
|
|
25
|
+
default:
|
|
26
|
+
return token.raw ?? "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function inlineToPlainText(tokens: Token[] | undefined): string {
|
|
31
|
+
if (!tokens || tokens.length === 0) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
return tokens.map((token) => plainFromToken(token)).join("");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type RenderInlineOptions = {
|
|
38
|
+
keyPrefix?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function renderInlineTokens(
|
|
42
|
+
tokens: Token[] | undefined,
|
|
43
|
+
options: RenderInlineOptions = {},
|
|
44
|
+
): React.ReactNode[] {
|
|
45
|
+
if (!tokens || tokens.length === 0) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const prefix = options.keyPrefix ?? "inline";
|
|
49
|
+
return tokens.flatMap((token, index) => {
|
|
50
|
+
const key = `${prefix}-${index}`;
|
|
51
|
+
switch (token.type) {
|
|
52
|
+
case "text":
|
|
53
|
+
return [token.text ?? ""];
|
|
54
|
+
case "escape":
|
|
55
|
+
return [token.text ?? token.raw ?? ""];
|
|
56
|
+
case "codespan":
|
|
57
|
+
return [
|
|
58
|
+
<Text key={key} color="green">
|
|
59
|
+
{token.text ?? ""}
|
|
60
|
+
</Text>,
|
|
61
|
+
];
|
|
62
|
+
case "strong":
|
|
63
|
+
return [
|
|
64
|
+
<Text key={key} bold>
|
|
65
|
+
{renderInlineTokens(token.tokens, { keyPrefix: key })}
|
|
66
|
+
</Text>,
|
|
67
|
+
];
|
|
68
|
+
case "em":
|
|
69
|
+
return [
|
|
70
|
+
<Text key={key} italic>
|
|
71
|
+
{renderInlineTokens(token.tokens, { keyPrefix: key })}
|
|
72
|
+
</Text>,
|
|
73
|
+
];
|
|
74
|
+
case "del":
|
|
75
|
+
return [
|
|
76
|
+
<Text key={key} dimColor>
|
|
77
|
+
{renderInlineTokens(token.tokens, { keyPrefix: key })}
|
|
78
|
+
</Text>,
|
|
79
|
+
];
|
|
80
|
+
case "link": {
|
|
81
|
+
const href = token.href ?? "";
|
|
82
|
+
const text = inlineToPlainText(token.tokens).trim();
|
|
83
|
+
const label = text || href;
|
|
84
|
+
const shouldShowHref = Boolean(href) && label !== href;
|
|
85
|
+
return [
|
|
86
|
+
<Text key={`${key}-label`} color="blue" underline>
|
|
87
|
+
{label}
|
|
88
|
+
</Text>,
|
|
89
|
+
...(shouldShowHref
|
|
90
|
+
? [
|
|
91
|
+
<Text key={`${key}-href`} color="gray">
|
|
92
|
+
{` (${href})`}
|
|
93
|
+
</Text>,
|
|
94
|
+
]
|
|
95
|
+
: []),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
case "image": {
|
|
99
|
+
const label = token.text?.trim() || "image";
|
|
100
|
+
const href = token.href ?? "";
|
|
101
|
+
return [
|
|
102
|
+
<Text key={`${key}-img-label`} color="magenta">
|
|
103
|
+
{`[${label}]`}
|
|
104
|
+
</Text>,
|
|
105
|
+
...(href
|
|
106
|
+
? [
|
|
107
|
+
<Text key={`${key}-img-href`} color="gray">
|
|
108
|
+
{` (${href})`}
|
|
109
|
+
</Text>,
|
|
110
|
+
]
|
|
111
|
+
: []),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
case "br":
|
|
115
|
+
return [<Text key={key}>{"\n"}</Text>];
|
|
116
|
+
default: {
|
|
117
|
+
const fallback = plainFromToken(token);
|
|
118
|
+
return fallback ? [fallback] : [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Box } from "ink";
|
|
2
|
+
import { BlockRenderer } from "./BlockRenderer.tsx";
|
|
3
|
+
import { useMarkdownTokens } from "./hooks/useMarkdownTokens.ts";
|
|
4
|
+
|
|
5
|
+
type MarkdownMessageProps = {
|
|
6
|
+
children: string;
|
|
7
|
+
streaming?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function MarkdownMessage({
|
|
11
|
+
children,
|
|
12
|
+
streaming = false,
|
|
13
|
+
}: MarkdownMessageProps) {
|
|
14
|
+
const content = children ?? "";
|
|
15
|
+
const {
|
|
16
|
+
fullTokens,
|
|
17
|
+
stablePrefix,
|
|
18
|
+
unstableSuffix,
|
|
19
|
+
stableTokens,
|
|
20
|
+
unstableTokens,
|
|
21
|
+
} = useMarkdownTokens(content, streaming);
|
|
22
|
+
|
|
23
|
+
if (!streaming) {
|
|
24
|
+
return <BlockRenderer tokens={fullTokens} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Box flexDirection="column">
|
|
29
|
+
{stablePrefix ? <BlockRenderer tokens={stableTokens} /> : null}
|
|
30
|
+
{unstableSuffix ? (
|
|
31
|
+
<BlockRenderer tokens={unstableTokens} streaming />
|
|
32
|
+
) : null}
|
|
33
|
+
</Box>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
2
|
+
import type { Tokens } from "marked";
|
|
3
|
+
import { useMarkdownTableLayout } from "./hooks/useMarkdownTableLayout.ts";
|
|
4
|
+
|
|
5
|
+
type MarkdownTableProps = {
|
|
6
|
+
token: Tokens.Table;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function MarkdownTable({ token }: MarkdownTableProps) {
|
|
10
|
+
const { columns } = useWindowSize();
|
|
11
|
+
const layout = useMarkdownTableLayout(token, columns);
|
|
12
|
+
if (layout.lines.length === 0) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
18
|
+
{layout.lines.map((line, index) => (
|
|
19
|
+
<Text key={index}>{line}</Text>
|
|
20
|
+
))}
|
|
21
|
+
</Box>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { Token, Tokens } from "marked";
|
|
3
|
+
import { inlineToPlainText } from "../InlineRenderer.tsx";
|
|
4
|
+
|
|
5
|
+
const MIN_COLUMN_WIDTH = 3;
|
|
6
|
+
const MAX_ROW_LINES = 4;
|
|
7
|
+
const SAFETY_MARGIN = 4;
|
|
8
|
+
|
|
9
|
+
type TableLayout = {
|
|
10
|
+
mode: "horizontal" | "vertical";
|
|
11
|
+
lines: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function displayWidth(value: string): number {
|
|
15
|
+
return Array.from(value).length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function longestWordWidth(value: string): number {
|
|
19
|
+
const words = value.split(/\s+/).filter(Boolean);
|
|
20
|
+
if (words.length === 0) {
|
|
21
|
+
return MIN_COLUMN_WIDTH;
|
|
22
|
+
}
|
|
23
|
+
return Math.max(...words.map((word) => displayWidth(word)), MIN_COLUMN_WIDTH);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function wrapText(value: string, width: number): string[] {
|
|
27
|
+
if (width <= 0) {
|
|
28
|
+
return [value];
|
|
29
|
+
}
|
|
30
|
+
const normalized = value.replace(/\r\n/g, "\n").trimEnd();
|
|
31
|
+
const sourceLines = normalized.length > 0 ? normalized.split("\n") : [""];
|
|
32
|
+
const wrapped = sourceLines.flatMap((line) => {
|
|
33
|
+
const words = line.split(/\s+/).filter(Boolean);
|
|
34
|
+
if (words.length === 0) {
|
|
35
|
+
return [""];
|
|
36
|
+
}
|
|
37
|
+
const output: string[] = [];
|
|
38
|
+
let current = "";
|
|
39
|
+
for (const word of words) {
|
|
40
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
41
|
+
if (displayWidth(candidate) <= width) {
|
|
42
|
+
current = candidate;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (current) {
|
|
46
|
+
output.push(current);
|
|
47
|
+
}
|
|
48
|
+
if (displayWidth(word) <= width) {
|
|
49
|
+
current = word;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let remaining = word;
|
|
53
|
+
while (displayWidth(remaining) > width) {
|
|
54
|
+
output.push(Array.from(remaining).slice(0, width).join(""));
|
|
55
|
+
remaining = Array.from(remaining).slice(width).join("");
|
|
56
|
+
}
|
|
57
|
+
current = remaining;
|
|
58
|
+
}
|
|
59
|
+
if (current) {
|
|
60
|
+
output.push(current);
|
|
61
|
+
}
|
|
62
|
+
return output.length > 0 ? output : [""];
|
|
63
|
+
});
|
|
64
|
+
return wrapped.length > 0 ? wrapped : [""];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function padAligned(
|
|
68
|
+
value: string,
|
|
69
|
+
width: number,
|
|
70
|
+
align: "left" | "center" | "right" | null | undefined,
|
|
71
|
+
): string {
|
|
72
|
+
const padding = Math.max(0, width - displayWidth(value));
|
|
73
|
+
if (align === "center") {
|
|
74
|
+
const leftPad = Math.floor(padding / 2);
|
|
75
|
+
return `${" ".repeat(leftPad)}${value}${" ".repeat(padding - leftPad)}`;
|
|
76
|
+
}
|
|
77
|
+
if (align === "right") {
|
|
78
|
+
return `${" ".repeat(padding)}${value}`;
|
|
79
|
+
}
|
|
80
|
+
return `${value}${" ".repeat(padding)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rowToCells(row: Array<{ tokens?: Token[] }>): string[] {
|
|
84
|
+
return row.map((cell) => inlineToPlainText(cell.tokens).trim());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getVerticalLayout(
|
|
88
|
+
header: string[],
|
|
89
|
+
rows: string[][],
|
|
90
|
+
terminalWidth: number,
|
|
91
|
+
): TableLayout {
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
const separator = "─".repeat(Math.min(Math.max(terminalWidth - 1, 10), 40));
|
|
94
|
+
rows.forEach((row, rowIndex) => {
|
|
95
|
+
if (rowIndex > 0) {
|
|
96
|
+
lines.push(separator);
|
|
97
|
+
}
|
|
98
|
+
row.forEach((cell, columnIndex) => {
|
|
99
|
+
const label = header[columnIndex] || `Column ${columnIndex + 1}`;
|
|
100
|
+
const compact = cell.replace(/\s+/g, " ").trim();
|
|
101
|
+
const wrapped = wrapText(
|
|
102
|
+
compact,
|
|
103
|
+
Math.max(10, terminalWidth - displayWidth(label) - 3),
|
|
104
|
+
);
|
|
105
|
+
lines.push(`${label}: ${wrapped[0] ?? ""}`);
|
|
106
|
+
for (let index = 1; index < wrapped.length; index += 1) {
|
|
107
|
+
lines.push(` ${wrapped[index]}`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
return { mode: "vertical", lines };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function useMarkdownTableLayout(
|
|
115
|
+
token: Tokens.Table,
|
|
116
|
+
terminalWidth: number,
|
|
117
|
+
): TableLayout {
|
|
118
|
+
return useMemo(() => {
|
|
119
|
+
const header = rowToCells(token.header);
|
|
120
|
+
const rows = token.rows.map((row) => rowToCells(row));
|
|
121
|
+
const columnCount = header.length;
|
|
122
|
+
if (columnCount === 0) {
|
|
123
|
+
return { mode: "horizontal", lines: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const minWidths = header.map((_, columnIndex) => {
|
|
127
|
+
let max = longestWordWidth(header[columnIndex] ?? "");
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
max = Math.max(max, longestWordWidth(row[columnIndex] ?? ""));
|
|
130
|
+
}
|
|
131
|
+
return max;
|
|
132
|
+
});
|
|
133
|
+
const idealWidths = header.map((_, columnIndex) => {
|
|
134
|
+
let max = Math.max(
|
|
135
|
+
displayWidth(header[columnIndex] ?? ""),
|
|
136
|
+
MIN_COLUMN_WIDTH,
|
|
137
|
+
);
|
|
138
|
+
for (const row of rows) {
|
|
139
|
+
max = Math.max(max, displayWidth(row[columnIndex] ?? ""));
|
|
140
|
+
}
|
|
141
|
+
return max;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const borderOverhead = 1 + columnCount * 3;
|
|
145
|
+
const availableWidth = Math.max(
|
|
146
|
+
terminalWidth - borderOverhead - SAFETY_MARGIN,
|
|
147
|
+
columnCount * MIN_COLUMN_WIDTH,
|
|
148
|
+
);
|
|
149
|
+
const totalMin = minWidths.reduce((sum, width) => sum + width, 0);
|
|
150
|
+
const totalIdeal = idealWidths.reduce((sum, width) => sum + width, 0);
|
|
151
|
+
|
|
152
|
+
let columnWidths: number[];
|
|
153
|
+
if (totalIdeal <= availableWidth) {
|
|
154
|
+
columnWidths = idealWidths;
|
|
155
|
+
} else if (totalMin <= availableWidth) {
|
|
156
|
+
const remaining = availableWidth - totalMin;
|
|
157
|
+
const overflows = idealWidths.map(
|
|
158
|
+
(ideal, index) => ideal - minWidths[index]!,
|
|
159
|
+
);
|
|
160
|
+
const totalOverflow = overflows.reduce(
|
|
161
|
+
(sum, overflow) => sum + overflow,
|
|
162
|
+
0,
|
|
163
|
+
);
|
|
164
|
+
columnWidths = minWidths.map((minWidth, index) => {
|
|
165
|
+
if (totalOverflow === 0) {
|
|
166
|
+
return minWidth;
|
|
167
|
+
}
|
|
168
|
+
const extra = Math.floor(
|
|
169
|
+
(overflows[index]! / totalOverflow) * remaining,
|
|
170
|
+
);
|
|
171
|
+
return minWidth + extra;
|
|
172
|
+
});
|
|
173
|
+
} else {
|
|
174
|
+
const ratio = availableWidth / totalMin;
|
|
175
|
+
columnWidths = minWidths.map((width) =>
|
|
176
|
+
Math.max(Math.floor(width * ratio), MIN_COLUMN_WIDTH),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const maxRowLines = [header, ...rows]
|
|
181
|
+
.map((row) =>
|
|
182
|
+
row.reduce((max, cell, index) => {
|
|
183
|
+
const wrapped = wrapText(cell, columnWidths[index]!);
|
|
184
|
+
return Math.max(max, wrapped.length);
|
|
185
|
+
}, 1),
|
|
186
|
+
)
|
|
187
|
+
.reduce((max, count) => Math.max(max, count), 1);
|
|
188
|
+
|
|
189
|
+
if (maxRowLines > MAX_ROW_LINES) {
|
|
190
|
+
return getVerticalLayout(header, rows, terminalWidth);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function border(type: "top" | "middle" | "bottom"): string {
|
|
194
|
+
const chars = {
|
|
195
|
+
top: ["┌", "─", "┬", "┐"],
|
|
196
|
+
middle: ["├", "─", "┼", "┤"],
|
|
197
|
+
bottom: ["└", "─", "┴", "┘"],
|
|
198
|
+
}[type] as [string, string, string, string];
|
|
199
|
+
const [left, line, cross, right] = chars;
|
|
200
|
+
return `${left}${columnWidths
|
|
201
|
+
.map((width) => line.repeat(width + 2))
|
|
202
|
+
.join(cross)}${right}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderRow(
|
|
206
|
+
row: string[],
|
|
207
|
+
isHeader: boolean,
|
|
208
|
+
align: Array<"left" | "center" | "right" | null | undefined>,
|
|
209
|
+
): string[] {
|
|
210
|
+
const cells = row.map((cell, index) =>
|
|
211
|
+
wrapText(cell, columnWidths[index]!),
|
|
212
|
+
);
|
|
213
|
+
const lineCount = Math.max(...cells.map((lines) => lines.length), 1);
|
|
214
|
+
const output: string[] = [];
|
|
215
|
+
for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
|
|
216
|
+
let line = "│";
|
|
217
|
+
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
|
|
218
|
+
const value = cells[columnIndex]?.[lineIndex] ?? "";
|
|
219
|
+
const cellAlign = isHeader ? "center" : align[columnIndex];
|
|
220
|
+
line += ` ${padAligned(value, columnWidths[columnIndex]!, cellAlign)} │`;
|
|
221
|
+
}
|
|
222
|
+
output.push(line);
|
|
223
|
+
}
|
|
224
|
+
return output;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const alignments = token.align ?? [];
|
|
228
|
+
const lines: string[] = [
|
|
229
|
+
border("top"),
|
|
230
|
+
...renderRow(header, true, alignments),
|
|
231
|
+
border("middle"),
|
|
232
|
+
];
|
|
233
|
+
rows.forEach((row, rowIndex) => {
|
|
234
|
+
lines.push(...renderRow(row, false, alignments));
|
|
235
|
+
if (rowIndex < rows.length - 1) {
|
|
236
|
+
lines.push(border("middle"));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
lines.push(border("bottom"));
|
|
240
|
+
return { mode: "horizontal", lines };
|
|
241
|
+
}, [terminalWidth, token]);
|
|
242
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useMemo, useRef } from "react";
|
|
2
|
+
import { type Token } from "marked";
|
|
3
|
+
import { lexMarkdown, splitStreamingMarkdown } from "../lexer.ts";
|
|
4
|
+
|
|
5
|
+
export type MarkdownTokenPlan = {
|
|
6
|
+
fullTokens: Token[];
|
|
7
|
+
stablePrefix: string;
|
|
8
|
+
unstableSuffix: string;
|
|
9
|
+
stableTokens: Token[];
|
|
10
|
+
unstableTokens: Token[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function useMarkdownTokens(
|
|
14
|
+
content: string,
|
|
15
|
+
streaming: boolean,
|
|
16
|
+
): MarkdownTokenPlan {
|
|
17
|
+
const stablePrefixRef = useRef("");
|
|
18
|
+
const fullTokens = useMemo(() => lexMarkdown(content), [content]);
|
|
19
|
+
|
|
20
|
+
const streamSegments = streaming
|
|
21
|
+
? splitStreamingMarkdown(content, stablePrefixRef.current)
|
|
22
|
+
: { stablePrefix: "", unstableSuffix: "" };
|
|
23
|
+
|
|
24
|
+
const stableTokens = useMemo(
|
|
25
|
+
() => lexMarkdown(streamSegments.stablePrefix),
|
|
26
|
+
[streamSegments.stablePrefix],
|
|
27
|
+
);
|
|
28
|
+
const unstableTokens = useMemo(
|
|
29
|
+
() => lexMarkdown(streamSegments.unstableSuffix),
|
|
30
|
+
[streamSegments.unstableSuffix],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (!streaming) {
|
|
34
|
+
stablePrefixRef.current = "";
|
|
35
|
+
} else {
|
|
36
|
+
stablePrefixRef.current = streamSegments.stablePrefix;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
fullTokens,
|
|
41
|
+
stablePrefix: streamSegments.stablePrefix,
|
|
42
|
+
unstableSuffix: streamSegments.unstableSuffix,
|
|
43
|
+
stableTokens,
|
|
44
|
+
unstableTokens,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { marked, type Token } from "marked";
|
|
2
|
+
|
|
3
|
+
const TOKEN_CACHE_MAX = 500;
|
|
4
|
+
const tokenCache = new Map<string, Token[]>();
|
|
5
|
+
let markedConfigured = false;
|
|
6
|
+
|
|
7
|
+
const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /;
|
|
8
|
+
|
|
9
|
+
function hashContent(input: string): string {
|
|
10
|
+
let hash = 5381;
|
|
11
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
12
|
+
hash = (hash * 33) ^ input.charCodeAt(index);
|
|
13
|
+
}
|
|
14
|
+
return (hash >>> 0).toString(36);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasMarkdownSyntax(value: string): boolean {
|
|
18
|
+
const sample = value.length > 500 ? value.slice(0, 500) : value;
|
|
19
|
+
return MD_SYNTAX_RE.test(sample);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function paragraphToken(value: string): Token[] {
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
type: "paragraph",
|
|
26
|
+
raw: value,
|
|
27
|
+
text: value,
|
|
28
|
+
tokens: [
|
|
29
|
+
{
|
|
30
|
+
type: "text",
|
|
31
|
+
raw: value,
|
|
32
|
+
text: value,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
} as Token,
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lexer(content: string): Token[] {
|
|
40
|
+
configureMarked();
|
|
41
|
+
if (!hasMarkdownSyntax(content)) {
|
|
42
|
+
return paragraphToken(content);
|
|
43
|
+
}
|
|
44
|
+
return marked.lexer(content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function configureMarked(): void {
|
|
48
|
+
if (markedConfigured) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
markedConfigured = true;
|
|
52
|
+
marked.use({ gfm: true, breaks: false });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function lexMarkdown(content: string): Token[] {
|
|
56
|
+
const key = hashContent(content);
|
|
57
|
+
const hit = tokenCache.get(key);
|
|
58
|
+
if (hit) {
|
|
59
|
+
tokenCache.delete(key);
|
|
60
|
+
tokenCache.set(key, hit);
|
|
61
|
+
return hit;
|
|
62
|
+
}
|
|
63
|
+
const tokens = lexer(content);
|
|
64
|
+
if (tokenCache.size >= TOKEN_CACHE_MAX) {
|
|
65
|
+
const oldest = tokenCache.keys().next().value;
|
|
66
|
+
if (oldest !== undefined) {
|
|
67
|
+
tokenCache.delete(oldest);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
tokenCache.set(key, tokens);
|
|
71
|
+
return tokens;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function splitStreamingMarkdown(
|
|
75
|
+
content: string,
|
|
76
|
+
stablePrefix: string,
|
|
77
|
+
): { stablePrefix: string; unstableSuffix: string } {
|
|
78
|
+
configureMarked();
|
|
79
|
+
let currentStablePrefix = stablePrefix;
|
|
80
|
+
if (!content.startsWith(currentStablePrefix)) {
|
|
81
|
+
currentStablePrefix = "";
|
|
82
|
+
}
|
|
83
|
+
const boundary = currentStablePrefix.length;
|
|
84
|
+
const tokens = lexer(content.slice(boundary));
|
|
85
|
+
let lastContentIndex = tokens.length - 1;
|
|
86
|
+
while (lastContentIndex >= 0 && tokens[lastContentIndex]?.type === "space") {
|
|
87
|
+
lastContentIndex -= 1;
|
|
88
|
+
}
|
|
89
|
+
let advance = 0;
|
|
90
|
+
for (let index = 0; index < lastContentIndex; index += 1) {
|
|
91
|
+
advance += tokens[index]?.raw.length ?? 0;
|
|
92
|
+
}
|
|
93
|
+
if (advance > 0) {
|
|
94
|
+
currentStablePrefix = content.slice(0, boundary + advance);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
stablePrefix: currentStablePrefix,
|
|
98
|
+
unstableSuffix: content.slice(currentStablePrefix.length),
|
|
99
|
+
};
|
|
100
|
+
}
|