hoomanjs 1.13.0 → 1.15.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/acp/utils/tool-kind.ts +15 -0
- 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/src/configure/app.tsx +17 -0
- package/src/core/agent/index.ts +38 -15
- package/src/core/agents/definitions.ts +47 -0
- package/src/core/agents/index.ts +15 -0
- package/src/core/agents/registry.ts +108 -0
- package/src/core/agents/runner.ts +375 -0
- package/src/core/agents/tools.ts +100 -0
- package/src/core/approvals/allowed-tools.ts +29 -4
- package/src/core/config.ts +23 -0
- package/src/core/prompts/agents/plan.md +35 -0
- package/src/core/prompts/agents/research.md +32 -0
- package/src/core/prompts/environment.ts +62 -0
- package/src/core/prompts/static/environment.md +15 -0
- package/src/core/prompts/static/sleep.md +20 -0
- package/src/core/prompts/static/subagents.md +28 -0
- package/src/core/prompts/system.ts +9 -0
- package/src/core/tools/index.ts +1 -0
- package/src/core/tools/sleep.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoomanjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.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",
|
|
@@ -14,6 +14,7 @@ const KNOWN_TOOL_KINDS = new Map<string, ToolKind>([
|
|
|
14
14
|
["search_files", "search"],
|
|
15
15
|
["get_file_info", "read"],
|
|
16
16
|
["shell", "execute"],
|
|
17
|
+
["sleep", "other"],
|
|
17
18
|
["fetch", "fetch"],
|
|
18
19
|
["wiki_list_files", "read"],
|
|
19
20
|
["wiki_read_file", "read"],
|
|
@@ -22,9 +23,23 @@ const KNOWN_TOOL_KINDS = new Map<string, ToolKind>([
|
|
|
22
23
|
["wiki_stats", "read"],
|
|
23
24
|
["wiki_search", "search"],
|
|
24
25
|
["think", "think"],
|
|
26
|
+
["run_agents", "other"],
|
|
25
27
|
["update_todos", "other"],
|
|
26
28
|
["get_current_time", "other"],
|
|
27
29
|
["convert_time", "other"],
|
|
30
|
+
["list_skills", "read"],
|
|
31
|
+
["search_skills", "search"],
|
|
32
|
+
["install_skill", "edit"],
|
|
33
|
+
["delete_skill", "edit"],
|
|
34
|
+
["store_memory", "edit"],
|
|
35
|
+
["search_memory", "search"],
|
|
36
|
+
["update_memory", "edit"],
|
|
37
|
+
["archive_memory", "edit"],
|
|
38
|
+
["list_mcp_servers", "read"],
|
|
39
|
+
["get_mcp_server", "read"],
|
|
40
|
+
["add_mcp_server", "edit"],
|
|
41
|
+
["update_mcp_server", "edit"],
|
|
42
|
+
["delete_mcp_server", "edit"],
|
|
28
43
|
]);
|
|
29
44
|
|
|
30
45
|
export { INTERNAL_ALWAYS_ALLOWED };
|
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
|
+
}
|