Skip to content

Commit 8b352ce

Browse files
committed
fix(cli): "maximum update depth reached" bug
1 parent 450b184 commit 8b352ce

File tree

2 files changed

+85
-223
lines changed

2 files changed

+85
-223
lines changed

cli/src/chat.tsx

Lines changed: 22 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -376,88 +376,46 @@ export const App = ({
376376

377377
useEffect(() => {
378378
const handleSigint = () => {
379-
const now = Date.now()
380-
if (now - lastSigintTimeRef.current < 50) {
381-
return
382-
}
383-
lastSigintTimeRef.current = now
384-
385-
// If already armed, exit immediately
386-
if (exitArmedRef.current) {
387-
const flushed = flushAnalytics()
388-
if (
389-
flushed &&
390-
typeof (flushed as Promise<void>).finally === 'function'
391-
) {
392-
;(flushed as Promise<void>).finally(() => process.exit(0))
393-
} else {
394-
process.exit(0)
395-
}
396-
return
397-
}
398-
399-
// First Ctrl+C - arm the exit and trigger UI update via state
400-
exitArmedRef.current = true
401-
402-
// Clear any existing timeout
403379
if (exitWarningTimeoutRef.current) {
404380
clearTimeout(exitWarningTimeoutRef.current)
405381
exitWarningTimeoutRef.current = null
406382
}
407383

408-
// Schedule state updates in next tick to avoid render conflicts
409-
setTimeout(() => {
410-
setExitWarning('Press Ctrl+C again within 3 seconds to exit')
411-
412-
if (isAuthenticated) {
413-
if (inputValue) {
414-
setInputValue('')
415-
}
416-
setInputFocused(true)
417-
setTimeout(() => {
418-
const handle = inputRef.current
419-
if (handle && typeof handle.focus === 'function') {
420-
handle.focus()
421-
}
422-
}, 0)
423-
}
424-
}, 0)
384+
exitArmedRef.current = false
385+
setExitWarning(null)
425386

426-
// Set timeout to disarm
427-
exitWarningTimeoutRef.current = setTimeout(() => {
428-
exitArmedRef.current = false
429-
setExitWarning(null)
430-
exitWarningTimeoutRef.current = null
431-
}, 3000)
387+
const flushed = flushAnalytics()
388+
if (flushed && typeof (flushed as Promise<void>).finally === 'function') {
389+
;(flushed as Promise<void>).finally(() => process.exit(0))
390+
} else {
391+
process.exit(0)
392+
}
432393
}
433394

434395
process.on('SIGINT', handleSigint)
435396
return () => {
436397
process.off('SIGINT', handleSigint)
437398
}
438-
}, [isAuthenticated, inputValue, setInputValue, setInputFocused, inputRef])
399+
}, [])
439400

440401
const handleCtrlC = useCallback(() => {
441-
if (exitArmedRef.current) {
442-
exitArmedRef.current = false
443-
setExitWarning(null)
402+
if (exitWarningTimeoutRef.current) {
403+
clearTimeout(exitWarningTimeoutRef.current)
404+
exitWarningTimeoutRef.current = null
405+
}
444406

445-
const flushed = flushAnalytics()
446-
if (flushed && typeof (flushed as Promise<void>).finally === 'function') {
447-
;(flushed as Promise<void>).finally(() => process.exit(0))
448-
} else {
449-
process.exit(0)
450-
}
451-
return true
407+
exitArmedRef.current = false
408+
setExitWarning(null)
409+
410+
const flushed = flushAnalytics()
411+
if (flushed && typeof (flushed as Promise<void>).finally === 'function') {
412+
;(flushed as Promise<void>).finally(() => process.exit(0))
413+
} else {
414+
process.exit(0)
452415
}
453416

454-
exitArmedRef.current = true
455-
setExitWarning('Press Ctrl+C again to exit')
456-
setInputValue('')
457-
setInputFocused(true)
458-
inputRef.current?.focus()
459417
return true
460-
}, [flushAnalytics, setExitWarning, setInputFocused, setInputValue])
418+
}, [setExitWarning])
461419

462420
const {
463421
slashContext,
Lines changed: 63 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
1-
import React, { type ReactNode } from 'react'
21
import remarkParse from 'remark-parse'
32
import { unified } from 'unified'
43

54
import { logger } from './logger'
65

76
import type {
8-
Root,
9-
Content,
10-
Text,
11-
Emphasis,
12-
Strong,
13-
InlineCode,
7+
Blockquote,
148
Code,
9+
Content,
1510
Heading,
11+
InlineCode,
1612
List,
1713
ListItem,
18-
Blockquote,
14+
Paragraph,
15+
Root,
16+
Text,
1917
} from 'mdast'
2018

2119
export interface MarkdownPalette {
@@ -82,170 +80,80 @@ const resolvePalette = (
8280

8381
const processor = unified().use(remarkParse)
8482

85-
// Render inline content - this is what gets placed INSIDE the <text> wrapper
86-
function renderInlineContent(
87-
node: Content,
88-
key: string | number | undefined,
89-
palette: MarkdownPalette,
90-
): ReactNode {
83+
function nodeToPlainText(node: Content | Root): string {
9184
switch (node.type) {
92-
case 'text':
93-
return (node as Text).value
85+
case 'root':
86+
return (node as Root).children.map(nodeToPlainText).join('')
9487

95-
case 'emphasis':
88+
case 'paragraph':
9689
return (
97-
<em key={key}>
98-
{(node as Emphasis).children.map((child, index) =>
99-
renderInlineContent(child, index, palette),
100-
)}
101-
</em>
90+
(node as Paragraph).children.map(nodeToPlainText).join('') + '\n\n'
10291
)
10392

104-
case 'strong':
105-
return (
106-
<strong key={key}>
107-
{(node as Strong).children.map((child, index) =>
108-
renderInlineContent(child, index, palette),
109-
)}
110-
</strong>
111-
)
93+
case 'text':
94+
return (node as Text).value
11295

11396
case 'inlineCode':
114-
return (
115-
<span key={key} fg={palette.inlineCodeFg}>
116-
{(node as InlineCode).value}
117-
</span>
118-
)
97+
return `\`${(node as InlineCode).value}\``
11998

120-
case 'break':
121-
return '\n'
99+
case 'heading': {
100+
const heading = node as Heading
101+
const prefix = '#'.repeat(Math.max(1, Math.min(heading.depth, 6)))
102+
const content = heading.children.map(nodeToPlainText).join('')
103+
return `${prefix} ${content}\n\n`
104+
}
122105

123-
default:
124-
return null
125-
}
126-
}
106+
case 'list': {
107+
const list = node as List
108+
return (
109+
list.children
110+
.map((item, idx) => {
111+
const marker = list.ordered ? `${idx + 1}. ` : '- '
112+
const text = (item as ListItem).children
113+
.map(nodeToPlainText)
114+
.join('')
115+
.trimEnd()
116+
return marker + text
117+
})
118+
.join('\n') + '\n\n'
119+
)
120+
}
127121

128-
// Convert markdown AST to inline JSX elements
129-
function markdownToInline(
130-
node: Content | Root,
131-
palette: MarkdownPalette,
132-
): ReactNode[] {
133-
const result: ReactNode[] = []
122+
case 'listItem': {
123+
const listItem = node as ListItem
124+
return listItem.children.map(nodeToPlainText).join('')
125+
}
134126

135-
switch (node.type) {
136-
case 'root':
137-
node.children.forEach((child, index) => {
138-
result.push(...markdownToInline(child, palette))
139-
// Add spacing between blocks
140-
if (index < node.children.length - 1) {
141-
result.push('\n')
142-
}
143-
})
144-
break
127+
case 'blockquote': {
128+
const blockquote = node as Blockquote
129+
const content = blockquote.children
130+
.map((child) => nodeToPlainText(child).replace(/^/gm, '> '))
131+
.join('')
132+
return `${content}\n\n`
133+
}
145134

146-
case 'paragraph':
147-
node.children.forEach((child) => {
148-
result.push(renderInlineContent(child, undefined, palette))
149-
})
150-
result.push('\n')
151-
break
152-
153-
case 'heading':
154-
const headingNode = node as Heading
155-
const depth = headingNode.depth
156-
const headingPrefix = '#'.repeat(depth) + ' '
157-
const headingColor =
158-
palette.headingFg[depth] ?? palette.headingFg[2] ?? 'white'
159-
160-
result.push(
161-
<strong fg={headingColor}>
162-
{headingPrefix}
163-
{headingNode.children.map((child) =>
164-
renderInlineContent(child, undefined, palette),
165-
)}
166-
</strong>,
167-
)
168-
result.push('\n')
169-
break
170-
171-
case 'list':
172-
const listNode = node as List
173-
listNode.children.forEach((item, index) => {
174-
const bullet = listNode.ordered ? `${index + 1}. ` : '• '
175-
result.push(<span fg={palette.listBulletFg}>{bullet}</span>)
176-
177-
// Extract inline content from list item paragraphs
178-
const listItem = item as ListItem
179-
listItem.children.forEach((child) => {
180-
if (child.type === 'paragraph') {
181-
child.children.forEach((inlineChild) => {
182-
result.push(renderInlineContent(inlineChild, undefined, palette))
183-
})
184-
}
185-
})
186-
result.push('\n')
187-
})
188-
break
189-
190-
case 'code':
191-
const codeNode = node as Code
192-
const codeBg = palette.codeBackground
193-
const headerLabel = codeNode.lang ? `[${codeNode.lang}]` : '[code]'
194-
195-
result.push('\n')
196-
result.push(
197-
<span fg={palette.codeHeaderFg} bg={codeBg}>
198-
{headerLabel}
199-
</span>,
200-
)
201-
result.push('\n')
202-
result.push(
203-
<span fg={palette.codeTextFg} bg={codeBg}>
204-
{codeNode.value}
205-
</span>,
206-
)
207-
result.push('\n')
208-
break
209-
210-
case 'blockquote':
211-
const blockquoteNode = node as Blockquote
212-
result.push(<span fg={palette.blockquoteBorderFg}></span>)
213-
result.push(
214-
<em fg={palette.blockquoteTextFg}>
215-
{blockquoteNode.children.map((child) => {
216-
if (child.type === 'paragraph') {
217-
return child.children.map((inlineChild) =>
218-
renderInlineContent(inlineChild, undefined, palette),
219-
)
220-
}
221-
return null
222-
})}
223-
</em>,
224-
)
225-
result.push('\n')
226-
break
135+
case 'code': {
136+
const code = node as Code
137+
const header = code.lang ? `\`\`\`${code.lang}\n` : '```\n'
138+
return `${header}${code.value}\n\`\`\`\n\n`
139+
}
227140

228-
case 'thematicBreak':
229-
result.push(<span fg={palette.dividerFg}>{'─'.repeat(40)}</span>)
230-
result.push('\n')
231-
break
141+
default:
142+
return ''
232143
}
233-
234-
return result
235144
}
236145

237-
// Main function - returns inline JSX elements (no <text> wrapper)
238146
export function renderMarkdown(
239147
markdown: string,
240148
options: MarkdownRenderOptions = {},
241-
): ReactNode {
149+
): string {
242150
try {
243-
const ast = processor.parse(markdown)
244151
const palette = resolvePalette(options.palette)
245-
const inlineElements = markdownToInline(ast, palette)
152+
void palette // Keep signature compatibility for future color styling
246153

247-
// Return a fragment containing all inline elements
248-
return <>{inlineElements}</>
154+
const ast = processor.parse(markdown)
155+
const text = nodeToPlainText(ast).replace(/\s+$/g, '')
156+
return text
249157
} catch (error) {
250158
logger.error(error, 'Failed to parse markdown')
251159
return markdown
@@ -268,7 +176,7 @@ export function hasIncompleteCodeFence(content: string): boolean {
268176
export function renderStreamingMarkdown(
269177
content: string,
270178
options: MarkdownRenderOptions = {},
271-
): ReactNode {
179+
): string {
272180
if (!hasMarkdown(content)) {
273181
return content
274182
}
@@ -285,19 +193,15 @@ export function renderStreamingMarkdown(
285193
const completeSection = content.slice(0, lastFenceIndex)
286194
const pendingSection = content.slice(lastFenceIndex)
287195

288-
const nodes: ReactNode[] = []
196+
const parts: string[] = []
289197

290198
if (completeSection.length > 0) {
291-
nodes.push(renderMarkdown(completeSection, options))
199+
parts.push(renderMarkdown(completeSection, options))
292200
}
293201

294202
if (pendingSection.length > 0) {
295-
nodes.push(pendingSection)
296-
}
297-
298-
if (nodes.length === 1) {
299-
return nodes[0]
203+
parts.push(pendingSection)
300204
}
301205

302-
return React.createElement(React.Fragment, null, ...nodes)
206+
return parts.join('')
303207
}

0 commit comments

Comments
 (0)