1- import { For , Match , Show , Switch } from "solid-js" ;
2-
1+ import { Match , Show , Switch , createMemo } from "solid-js" ;
2+ import { marked } from "marked" ;
33import type { Part } from "@opencode-ai/sdk/v2/client" ;
4+ import { safeStringify } from "../utils" ;
45
56type Props = {
67 part : Part ;
@@ -10,90 +11,84 @@ type Props = {
1011 renderMarkdown ?: boolean ;
1112} ;
1213
13- type MarkdownSegment =
14- | { type : "text" ; text : string }
15- | { type : "code" ; text : string ; language : string } ;
16-
17- function safeStringify ( value : unknown ) {
18- const seen = new WeakSet < object > ( ) ;
19-
20- try {
21- return JSON . stringify (
22- value ,
23- ( key , val ) => {
24- if ( val && typeof val === "object" ) {
25- if ( seen . has ( val as object ) ) {
26- return "<circular>" ;
27- }
28- seen . add ( val as object ) ;
29- }
30-
31- const lowerKey = key . toLowerCase ( ) ;
32- if (
33- lowerKey === "reasoningencryptedcontent" ||
34- lowerKey . includes ( "api_key" ) ||
35- lowerKey . includes ( "apikey" ) ||
36- lowerKey . includes ( "access_token" ) ||
37- lowerKey . includes ( "refresh_token" ) ||
38- lowerKey . includes ( "token" ) ||
39- lowerKey . includes ( "authorization" ) ||
40- lowerKey . includes ( "cookie" ) ||
41- lowerKey . includes ( "secret" )
42- ) {
43- return "[redacted]" ;
44- }
45-
46- return val ;
47- } ,
48- 2 ,
49- ) ;
50- } catch {
51- return "<unserializable>" ;
52- }
53- }
54-
5514function clampText ( text : string , max = 800 ) {
5615 if ( text . length <= max ) return text ;
5716 return `${ text . slice ( 0 , max ) } \n\n… (truncated)` ;
5817}
5918
60- function parseMarkdownSegments ( text : string ) : MarkdownSegment [ ] {
61- if ( ! text . includes ( "```" ) ) {
62- return [ { type : "text" , text } ] ;
63- }
19+ function createCustomRenderer ( tone : "light" | "dark" ) {
20+ const renderer = new marked . Renderer ( ) ;
21+ const codeBlockClass =
22+ tone === "dark"
23+ ? "bg-gray-12/10 border-gray-11/20 text-gray-12"
24+ : "bg-gray-1/80 border-gray-6/70 text-gray-12" ;
25+ const inlineCodeClass =
26+ tone === "dark"
27+ ? "bg-gray-12/15 text-gray-12"
28+ : "bg-gray-2/70 text-gray-12" ;
29+
30+ const escapeHtml = ( s : string ) =>
31+ s . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) ;
32+
33+ const isSafeUrl = ( url : string ) => {
34+ const protocol = ( url || "" ) . trim ( ) . toLowerCase ( ) ;
35+ return ! protocol . startsWith ( "javascript:" ) && ! protocol . startsWith ( "data:" ) ;
36+ } ;
6437
65- const segments : MarkdownSegment [ ] = [ ] ;
66- const regex = / ` ` ` ( \w + ) ? \n ( [ \s \S ] * ?) ` ` ` / g;
67- let lastIndex = 0 ;
68- let match : RegExpExecArray | null = null ;
38+ renderer . html = ( { text } ) => escapeHtml ( text ) ;
6939
70- while ( ( match = regex . exec ( text ) ) !== null ) {
71- if ( match . index > lastIndex ) {
72- segments . push ( { type : "text" , text : text . slice ( lastIndex , match . index ) } ) ;
73- }
40+ renderer . code = ( { text, lang } ) => {
41+ const language = lang || "" ;
42+ return `
43+ <div class="rounded-2xl border px-4 py-3 my-4 ${ codeBlockClass } ">
44+ ${
45+ language
46+ ? `<div class="text-[10px] uppercase tracking-[0.2em] text-gray-9 mb-2">${ escapeHtml ( language ) } </div>`
47+ : ""
48+ }
49+ <pre class="overflow-x-auto whitespace-pre text-[13px] leading-relaxed font-mono"><code>${ escapeHtml (
50+ text
51+ ) } </code></pre>
52+ </div>
53+ ` ;
54+ } ;
7455
75- const language = match [ 1 ] ?. trim ( ) ?? "" ;
76- const code = match [ 2 ] ?. replace ( / \n $ / , "" ) ?? "" ;
77- segments . push ( { type : "code" , text : code , language } ) ;
78- lastIndex = regex . lastIndex ;
79- }
56+ renderer . codespan = ( { text } ) => {
57+ return `< code class="rounded-md px-1.5 py-0.5 text-[13px] font-mono ${ inlineCodeClass } "> ${ escapeHtml (
58+ text
59+ ) } </code>` ;
60+ } ;
8061
81- if ( lastIndex < text . length ) {
82- segments . push ( { type : "text" , text : text . slice ( lastIndex ) } ) ;
83- }
62+ renderer . link = ( { href, title, text } ) => {
63+ const safeHref = isSafeUrl ( href ) ? escapeHtml ( href ?? "#" ) : "#" ;
64+ const safeTitle = title ? escapeHtml ( title ) : "" ;
65+ return `
66+ <a
67+ href="${ safeHref } "
68+ target="_blank"
69+ rel="noopener noreferrer"
70+ class="underline underline-offset-2 text-blue-600 hover:text-blue-700"
71+ ${ safeTitle ? `title="${ safeTitle } "` : "" }
72+ >
73+ ${ text }
74+ </a>
75+ ` ;
76+ } ;
8477
85- return segments . length ? segments : [ { type : "text" , text } ] ;
86- }
78+ renderer . image = ( { href, title, text } ) => {
79+ const safeHref = isSafeUrl ( href ) ? escapeHtml ( href ?? "" ) : "" ;
80+ const safeTitle = title ? escapeHtml ( title ) : "" ;
81+ return `
82+ <img
83+ src="${ safeHref } "
84+ alt="${ escapeHtml ( text || "" ) } "
85+ ${ safeTitle ? `title="${ safeTitle } "` : "" }
86+ class="max-w-full h-auto rounded-lg my-4"
87+ />
88+ ` ;
89+ } ;
8790
88- function parseInlineCode ( text : string ) {
89- if ( ! text . includes ( "`" ) ) return [ { type : "text" , text } ] ;
90- const parts = text . split ( / ( ` [ ^ ` ] + ` ) / g) . filter ( Boolean ) ;
91- return parts . map ( ( part ) => {
92- if ( part . startsWith ( "`" ) && part . endsWith ( "`" ) ) {
93- return { type : "code" , text : part . slice ( 1 , - 1 ) } ;
94- }
95- return { type : "text" , text : part } ;
96- } ) ;
91+ return renderer ;
9792}
9893
9994export default function PartView ( props : Props ) {
@@ -106,56 +101,65 @@ export default function PartView(props: Props) {
106101 const textClass = ( ) => ( tone ( ) === "dark" ? "text-gray-12" : "text-gray-12" ) ;
107102 const subtleTextClass = ( ) => ( tone ( ) === "dark" ? "text-gray-12/70" : "text-gray-11" ) ;
108103 const panelBgClass = ( ) => ( tone ( ) === "dark" ? "bg-gray-2/10" : "bg-gray-2/30" ) ;
109- const inlineCodeClass = ( ) =>
110- tone ( ) === "dark"
111- ? "bg-gray-12/15 text-gray-12"
112- : "bg-gray-2/70 text-gray-12" ;
113- const codeBlockClass = ( ) =>
114- tone ( ) === "dark"
115- ? "bg-gray-12/10 border-gray-11/20 text-gray-12"
116- : "bg-gray-1/80 border-gray-6/70 text-gray-12" ;
117104 const toolOnly = ( ) => developerMode ( ) ;
118105 const showToolOutput = ( ) => developerMode ( ) ;
106+ const renderedMarkdown = createMemo ( ( ) => {
107+ if ( ! renderMarkdown ( ) || p ( ) . type !== "text" ) return null ;
108+ const text = "text" in p ( ) ? String ( ( p ( ) as { text : string } ) . text ?? "" ) : "" ;
109+ if ( ! text . trim ( ) ) return "" ;
110+
111+ try {
112+ const renderer = createCustomRenderer ( tone ( ) ) ;
113+ const result = marked . parse ( text , {
114+ breaks : true ,
115+ gfm : true ,
116+ renderer,
117+ async : false
118+ } ) ;
119+
120+ return typeof result === 'string' ? result : '' ;
121+ } catch ( error ) {
122+ console . error ( 'Markdown parsing error:' , error ) ;
123+ return null ;
124+ }
125+ } ) ;
119126
120127 return (
121128 < Switch >
122129 < Match when = { p ( ) . type === "text" } >
123130 < Show
124131 when = { renderMarkdown ( ) }
125132 fallback = {
126- < div class = { `whitespace-pre-wrap break-words ${ textClass ( ) } ` . trim ( ) } > { ( p ( ) as any ) . text } </ div >
133+ < div class = { `whitespace-pre-wrap break-words ${ textClass ( ) } ` . trim ( ) } >
134+ { "text" in p ( ) ? ( p ( ) as { text : string } ) . text : "" }
135+ </ div >
127136 }
128137 >
129- < div class = "space-y-3" >
130- < For each = { parseMarkdownSegments ( String ( ( p ( ) as any ) . text ?? "" ) ) } >
131- { ( segment ) =>
132- segment . type === "code" ? (
133- < div class = { `rounded-2xl border px-4 py-3 ${ codeBlockClass ( ) } ` . trim ( ) } >
134- < Show when = { segment . language } >
135- < div class = "text-[10px] uppercase tracking-[0.2em] text-gray-9 mb-2" >
136- { segment . language }
137- </ div >
138- </ Show >
139- < pre class = "overflow-x-auto whitespace-pre text-[13px] leading-relaxed font-mono" >
140- { segment . text }
141- </ pre >
142- </ div >
143- ) : (
144- < div class = { `whitespace-pre-wrap break-words ${ textClass ( ) } ` . trim ( ) } >
145- { parseInlineCode ( segment . text ) . map ( ( part ) =>
146- part . type === "code" ? (
147- < code class = { `rounded-md px-1.5 py-0.5 text-[13px] font-mono ${ inlineCodeClass ( ) } ` . trim ( ) } >
148- { part . text }
149- </ code >
150- ) : (
151- part . text
152- ) ,
153- ) }
154- </ div >
155- )
156- }
157- </ For >
158- </ div >
138+ < Show
139+ when = { renderedMarkdown ( ) }
140+ fallback = {
141+ < div class = { `whitespace-pre-wrap break-words ${ textClass ( ) } ` . trim ( ) } >
142+ { "text" in p ( ) ? ( p ( ) as { text : string } ) . text : "" }
143+ </ div >
144+ }
145+ >
146+ < div
147+ class = { `markdown-content max-w-none ${ textClass ( ) }
148+ [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:my-4
149+ [&_h2]:text-xl [&_h2]:font-bold [&_h2]:my-3
150+ [&_h3]:text-lg [&_h3]:font-bold [&_h3]:my-2
151+ [&_p]:my-3 [&_p]:leading-relaxed
152+ [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-3
153+ [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-3
154+ [&_li]:my-1
155+ [&_blockquote]:border-l-4 [&_blockquote]:border-gray-300 [&_blockquote]:pl-4 [&_blockquote]:my-4 [&_blockquote]:italic
156+ [&_table]:w-full [&_table]:border-collapse [&_table]:my-4
157+ [&_th]:border [&_th]:border-gray-300 [&_th]:p-2 [&_th]:bg-gray-50
158+ [&_td]:border [&_td]:border-gray-300 [&_td]:p-2
159+ ` . trim ( ) }
160+ innerHTML = { renderedMarkdown ( ) ! }
161+ />
162+ </ Show >
159163 </ Show >
160164 </ Match >
161165
@@ -164,8 +168,9 @@ export default function PartView(props: Props) {
164168 when = {
165169 showThinking ( ) &&
166170 developerMode ( ) &&
167- typeof ( p ( ) as any ) . text === "string" &&
168- ( p ( ) as any ) . text . trim ( )
171+ "text" in p ( ) &&
172+ typeof ( p ( ) as { text : string } ) . text === "string" &&
173+ ( p ( ) as { text : string } ) . text . trim ( )
169174 }
170175 >
171176 < details class = { `rounded-lg ${ panelBgClass ( ) } p-2` . trim ( ) } >
@@ -175,7 +180,7 @@ export default function PartView(props: Props) {
175180 tone ( ) === "dark" ? "text-gray-1" : "text-gray-12"
176181 } `. trim ( ) }
177182 >
178- { clampText ( String ( ( p ( ) as any ) . text ) , 2000 ) }
183+ { clampText ( String ( ( p ( ) as { text : string } ) . text ) , 2000 ) }
179184 </ pre >
180185 </ details >
181186 </ Show >
@@ -188,24 +193,24 @@ export default function PartView(props: Props) {
188193 < div
189194 class = { `text-xs font-medium ${ tone ( ) === "dark" ? "text-gray-1" : "text-gray-12" } ` . trim ( ) }
190195 >
191- Tool · { String ( ( p ( ) as any ) . tool ) }
196+ Tool · { ( "tool" in p ( ) ? String ( ( p ( ) as { tool : string } ) . tool ) : "unknown" ) }
192197 </ div >
193198 < div
194199 class = { `rounded-full px-2 py-0.5 text-[11px] font-medium ${
195- ( p ( ) as any ) . state ?. status === "completed"
200+ "state" in p ( ) && ( p ( ) as any ) . state ?. status === "completed"
196201 ? "bg-green-3/15 text-green-12"
197- : ( p ( ) as any ) . state ?. status === "running"
202+ : "state" in p ( ) && ( p ( ) as any ) . state ?. status === "running"
198203 ? "bg-blue-3/15 text-blue-12"
199- : ( p ( ) as any ) . state ?. status === "error"
204+ : "state" in p ( ) && ( p ( ) as any ) . state ?. status === "error"
200205 ? "bg-red-3/15 text-red-12"
201206 : "bg-gray-2/10 text-gray-1"
202207 } `}
203208 >
204- { String ( ( p ( ) as any ) . state ?. status ?? "unknown" ) }
209+ { ( "state" in p ( ) ? String ( ( p ( ) as any ) . state ?. status ?? "unknown" ) : "unknown" ) }
205210 </ div >
206211 </ div >
207212
208- < Show when = { ( p ( ) as any ) . state ?. title } >
213+ < Show when = { "state" in p ( ) && ( p ( ) as any ) . state ?. title } >
209214 < div class = { `text-xs ${ subtleTextClass ( ) } ` . trim ( ) } > { String ( ( p ( ) as any ) . state . title ) } </ div >
210215 </ Show >
211216
@@ -244,7 +249,7 @@ export default function PartView(props: Props) {
244249 < Match when = { p ( ) . type === "step-start" || p ( ) . type === "step-finish" } >
245250 < div class = { `text-xs ${ subtleTextClass ( ) } ` . trim ( ) } >
246251 { p ( ) . type === "step-start" ? "Step started" : "Step finished" }
247- < Show when = { ( p ( ) as any ) . reason } >
252+ < Show when = { "reason" in p ( ) && ( p ( ) as any ) . reason } >
248253 < span class = { tone ( ) === "dark" ? "text-gray-12/80" : "text-gray-11" } >
249254 { " " } · { String ( ( p ( ) as any ) . reason ) }
250255 </ span >
@@ -265,4 +270,4 @@ export default function PartView(props: Props) {
265270 </ Match >
266271 </ Switch >
267272 ) ;
268- }
273+ }
0 commit comments