Skip to content

Commit 1ded1c6

Browse files
committed
Add MarkdownRenderer component for rendering markdown in chat messages
1 parent 9fcdee2 commit 1ded1c6

3 files changed

Lines changed: 149 additions & 3 deletions

File tree

components/agent/chat_panel.cl.jac

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ cl import from "lucide-react" {
99
cl import from ...store.mcp_store { useMcpStore }
1010
cl import from ...hooks.useAgentLoop { useAgentLoop }
1111
cl import from ...components.ui.json_viewer { JsonViewer }
12+
cl import from ...components.ui.markdown_renderer { MarkdownRenderer }
1213

1314

1415
# ── Tool Call Card ──────────────────────────────────────────────────────
@@ -112,7 +113,7 @@ def ChatMessage(props: Any) -> JsxElement {
112113
</div>
113114
<div className="max-w-[85%] space-y-2">
114115
{msg.content and <div className="bg-card border border-border rounded-2xl rounded-bl-md px-4 py-2.5">
115-
<p className="text-sm text-foreground whitespace-pre-wrap">{msg.content}</p>
116+
<MarkdownRenderer content={msg.content}/>
116117
</div>}
117118
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-1">
118119
<Wrench size={11}/>
@@ -132,7 +133,7 @@ def ChatMessage(props: Any) -> JsxElement {
132133
<Bot size={13} className="text-info"/>
133134
</div>
134135
<div className="max-w-[85%] bg-card border border-border rounded-2xl rounded-bl-md px-4 py-2.5">
135-
<p className="text-sm text-foreground whitespace-pre-wrap">{msg.content}</p>
136+
<MarkdownRenderer content={msg.content}/>
136137
</div>
137138
</div>;
138139
}

components/agent/llm_config.cl.jac

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ cl import from "lucide-react" {
77
}
88
cl import from ...store.mcp_store { useMcpStore }
99

10+
glob API_KEY_PLACEHOLDERS: Any = {
11+
"openai": "sk-...",
12+
"anthropic": "sk-ant-...",
13+
"google": "AIza...",
14+
"groq": "gsk_...",
15+
"together": "..."
16+
};
17+
1018
glob MODEL_SUGGESTIONS: Any = {
1119
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1-mini", "o3-mini"],
1220
"anthropic": ["claude-sonnet-4-20250514", "claude-haiku-4-20250414", "claude-opus-4-20250514"],
@@ -283,7 +291,7 @@ def:pub LlmConfigPanel -> JsxElement {
283291
type={showKey and "text" or "password"}
284292
value={localApiKey}
285293
onChange={lambda(e: Any) -> None { localApiKey = e.target.value; }}
286-
placeholder="sk-..."
294+
placeholder={API_KEY_PLACEHOLDERS[localProvider] or "API key..."}
287295
className="w-full bg-background border border-input rounded-lg px-3 py-2 pr-10 text-sm text-foreground font-mono placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/50"
288296
/>
289297
<button
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""MarkdownRenderer – lightweight markdown renderer for LLM chat responses."""
2+
3+
cl import from "react" { useState }
4+
cl import from "lucide-react" { Copy, Check }
5+
6+
7+
def CodeBlock(props: Any) -> JsxElement {
8+
language = props.language or "";
9+
code = props.code or "";
10+
has copied: bool = False;
11+
12+
def handleCopy -> None {
13+
navigator.clipboard.writeText(code).then(lambda -> Any {
14+
copied = True;
15+
setTimeout(lambda -> None { copied = False; }, 2000);
16+
return None;
17+
});
18+
}
19+
20+
return <div className="my-3 rounded-lg border border-border overflow-hidden">
21+
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b border-border">
22+
<span className="text-xs font-mono text-muted-foreground/70">{language or "code"}</span>
23+
<button
24+
onClick={handleCopy}
25+
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
26+
>
27+
{copied and <Check size={11} className="text-success"/> or <Copy size={11}/>}
28+
<span className="ml-0.5">{copied and "Copied!" or "Copy"}</span>
29+
</button>
30+
</div>
31+
<pre className="px-4 py-3 overflow-x-auto bg-muted/20 m-0">
32+
<code className="text-xs font-mono text-foreground leading-relaxed">{code}</code>
33+
</pre>
34+
</div>;
35+
}
36+
37+
38+
# Convert inline markdown (**bold**, `code`) to HTML string
39+
def mdInlineHtml(text: Any) -> Any {
40+
s = text;
41+
bParts = s.split("**");
42+
s = bParts.map(lambda(p: Any, i: Any) -> Any {
43+
return i % 2 == 1 and "<strong class='font-semibold text-foreground'>" + p + "</strong>" or p;
44+
}).join("");
45+
cParts = s.split("`");
46+
s = cParts.map(lambda(p: Any, i: Any) -> Any {
47+
return i % 2 == 1 and "<code style='font-family:monospace;font-size:0.8em;background:rgba(128,128,128,0.2);padding:0.1em 0.4em;border-radius:3px'>" + p + "</code>" or p;
48+
}).join("");
49+
return s;
50+
}
51+
52+
53+
# Parse a text segment (between code fences) into typed blocks
54+
def mdTextToBlocks(text: Any) -> Any {
55+
blocks = [];
56+
listItems = [];
57+
listOrdered = False;
58+
59+
for line in text.split(String.fromCharCode(10)) {
60+
s = line.trim();
61+
if s == "" {
62+
if listItems.length > 0 {
63+
blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)});
64+
listItems.length = 0;
65+
}
66+
} elif s.startsWith("### ") {
67+
if listItems.length > 0 { blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); listItems.length = 0; }
68+
blocks.push({"type": "h3", "text": s.substring(4)});
69+
} elif s.startsWith("## ") {
70+
if listItems.length > 0 { blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); listItems.length = 0; }
71+
blocks.push({"type": "h2", "text": s.substring(3)});
72+
} elif s.startsWith("# ") {
73+
if listItems.length > 0 { blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); listItems.length = 0; }
74+
blocks.push({"type": "h1", "text": s.substring(2)});
75+
} elif s.startsWith("- ") or s.startsWith("* ") {
76+
if listItems.length > 0 and listOrdered { blocks.push({"type": "list", "ordered": True, "items": listItems.slice(0)}); listItems.length = 0; }
77+
listOrdered = False;
78+
listItems.push(s.substring(2));
79+
} elif not isNaN(parseInt(s)) and s.indexOf(". ") > 0 {
80+
if listItems.length > 0 and not listOrdered { blocks.push({"type": "list", "ordered": False, "items": listItems.slice(0)}); listItems.length = 0; }
81+
listOrdered = True;
82+
dotIdx = s.indexOf(". ");
83+
listItems.push(s.substring(dotIdx + 2));
84+
} else {
85+
if listItems.length > 0 { blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)}); listItems.length = 0; }
86+
blocks.push({"type": "p", "text": s});
87+
}
88+
}
89+
if listItems.length > 0 {
90+
blocks.push({"type": "list", "ordered": listOrdered, "items": listItems.slice(0)});
91+
}
92+
return blocks;
93+
}
94+
95+
96+
def:pub MarkdownRenderer(props: Any) -> JsxElement {
97+
content = props.content or "";
98+
99+
# Split on ``` fences → alternate text / code
100+
parts = content.split("```");
101+
rendered = [];
102+
segIdx = 0;
103+
104+
for part in parts {
105+
if segIdx % 2 == 1 {
106+
lines = part.split(String.fromCharCode(10));
107+
lang = lines[0].trim();
108+
code = lines.slice(1).join(String.fromCharCode(10)).trimEnd();
109+
rendered.push(<CodeBlock key={segIdx} language={lang} code={code}/>);
110+
} else {
111+
for block in mdTextToBlocks(part) {
112+
blockKey = f"{segIdx}-{rendered.length}";
113+
if block.type == "h1" {
114+
rendered.push(<h1 key={blockKey} className="text-base font-bold mt-3 mb-1 text-foreground" dangerouslySetInnerHTML={{"__html": mdInlineHtml(block.text)}}/>);
115+
} elif block.type == "h2" {
116+
rendered.push(<h2 key={blockKey} className="text-sm font-bold mt-2.5 mb-1 text-foreground" dangerouslySetInnerHTML={{"__html": mdInlineHtml(block.text)}}/>);
117+
} elif block.type == "h3" {
118+
rendered.push(<h3 key={blockKey} className="text-sm font-semibold mt-2 mb-0.5 text-foreground" dangerouslySetInnerHTML={{"__html": mdInlineHtml(block.text)}}/>);
119+
} elif block.type == "list" {
120+
items = block.items.map(lambda(item: Any, ii: Any) -> Any {
121+
return <li key={ii} className="leading-relaxed" dangerouslySetInnerHTML={{"__html": mdInlineHtml(item)}}/>;
122+
});
123+
if block.ordered {
124+
rendered.push(<ol key={blockKey} className="text-sm list-decimal pl-5 my-1 space-y-0.5">{items}</ol>);
125+
} else {
126+
rendered.push(<ul key={blockKey} className="text-sm list-disc pl-5 my-1 space-y-0.5">{items}</ul>);
127+
}
128+
} else {
129+
rendered.push(<p key={blockKey} className="text-sm leading-relaxed" dangerouslySetInnerHTML={{"__html": mdInlineHtml(block.text)}}/>);
130+
}
131+
}
132+
}
133+
segIdx += 1;
134+
}
135+
136+
return <div className="text-sm text-foreground space-y-1.5">{rendered}</div>;
137+
}

0 commit comments

Comments
 (0)