Skip to content

Commit a10eef9

Browse files
committed
refactor: clean up thinking component implementation
- Add PREVIEW_LINE_COUNT constant - Consolidate duplicate reasoning block handling with renderThinkingBlock helper - Rename stripPlanTags to scrubPlanTags for consistency - Simplify getToolFinishedPreview ternary logic - Add auto-collapse for subagent thinking blocks - Improve code organization and maintainability
1 parent a30fe9e commit a10eef9

File tree

5 files changed

+299
-52
lines changed

5 files changed

+299
-52
lines changed

cli/src/components/message-block.tsx

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AgentBranchItem } from './agent-branch-item'
66
import { ElapsedTimer } from './elapsed-timer'
77
import { renderToolComponent } from './tools/registry'
88
import { ToolCallItem } from './tools/tool-call-item'
9+
import { Thinking } from './thinking'
910
import { useTheme } from '../hooks/use-theme'
1011
import { getToolDisplayInfo } from '../utils/codebuff-client'
1112
import {
@@ -24,6 +25,12 @@ const trimTrailingNewlines = (value: string): string =>
2425
const sanitizePreview = (value: string): string =>
2526
value.replace(/[#*_`~\[\]()]/g, '').trim()
2627

28+
// Delete any complete <cb_plan>...</cb_plan> segment; hide any partial open <cb_plan>... (until closing tag arrives)
29+
const scrubPlanTags = (value: string): string =>
30+
value
31+
.replace(/<cb_plan>[\s\S]*?<\/cb_plan>/g, '')
32+
.replace(/<cb_plan>[\s\S]*$/g, '')
33+
2734
interface MessageBlockProps {
2835
messageId: string
2936
blocks?: ContentBlock[]
@@ -100,9 +107,8 @@ export const MessageBlock = memo(
100107
.filter((line) => line.trim())
101108
const lastThreeLines = outputLines.slice(-3)
102109
const hasMoreLines = outputLines.length > 3
103-
return hasMoreLines
104-
? '...\n' + lastThreeLines.join('\n')
105-
: lastThreeLines.join('\n')
110+
const preview = lastThreeLines.join('\n')
111+
return hasMoreLines ? `...\n${preview}` : preview
106112
}
107113

108114
return sanitizePreview(lastLine)
@@ -120,6 +126,45 @@ export const MessageBlock = memo(
120126
}
121127
}
122128

129+
const isReasoningTextBlock = (b: any): boolean =>
130+
b?.type === 'text' &&
131+
(b.textType === 'reasoning' ||
132+
b.textType === 'reasoning_chunk' ||
133+
(typeof b.color === 'string' &&
134+
(b.color.toLowerCase() === 'grey' || b.color.toLowerCase() === 'gray')))
135+
136+
const renderThinkingBlock = (
137+
blocks: Extract<ContentBlock, { type: 'text' }>[],
138+
keyPrefix: string,
139+
startIndex: number,
140+
indentLevel: number = 0,
141+
): React.ReactNode => {
142+
const thinkingId = `${keyPrefix}-thinking-${startIndex}`
143+
const combinedContent = blocks
144+
.map((b) => b.content)
145+
.join('')
146+
.trim()
147+
148+
if (!combinedContent) {
149+
return null
150+
}
151+
152+
const isCollapsed = collapsedAgents.has(thinkingId)
153+
const marginLeft = Math.max(0, indentLevel * 2)
154+
const availWidth = Math.max(10, availableWidth - marginLeft - 4)
155+
156+
return (
157+
<box key={thinkingId} style={{ marginLeft }}>
158+
<Thinking
159+
content={scrubPlanTags(combinedContent)}
160+
isCollapsed={isCollapsed}
161+
onToggle={() => onToggleCollapsed(thinkingId)}
162+
availableWidth={availWidth}
163+
/>
164+
</box>
165+
)
166+
}
167+
123168
const renderToolBranch = (
124169
toolBlock: Extract<ContentBlock, { type: 'tool' }>,
125170
indentLevel: number,
@@ -363,6 +408,29 @@ export const MessageBlock = memo(
363408

364409
for (let nestedIdx = 0; nestedIdx < nestedBlocks.length; ) {
365410
const nestedBlock = nestedBlocks[nestedIdx]
411+
// Handle reasoning text blocks in agents
412+
if (isReasoningTextBlock(nestedBlock)) {
413+
const start = nestedIdx
414+
const reasoningBlocks: Extract<ContentBlock, { type: 'text' }>[] = []
415+
while (
416+
nestedIdx < nestedBlocks.length &&
417+
isReasoningTextBlock(nestedBlocks[nestedIdx] as any)
418+
) {
419+
reasoningBlocks.push(nestedBlocks[nestedIdx] as any)
420+
nestedIdx++
421+
}
422+
423+
const thinkingNode = renderThinkingBlock(
424+
reasoningBlocks,
425+
keyPrefix,
426+
start,
427+
indentLevel,
428+
)
429+
if (thinkingNode) {
430+
nodes.push(thinkingNode)
431+
}
432+
continue
433+
}
366434
switch (nestedBlock.type) {
367435
case 'text': {
368436
const nestedStatus =
@@ -374,10 +442,11 @@ export const MessageBlock = memo(
374442
const rawNestedContent = isNestedStreamingText
375443
? trimTrailingNewlines(nestedBlock.content)
376444
: nestedBlock.content.trim()
445+
const filteredNestedContent = scrubPlanTags(rawNestedContent)
377446
const renderKey = `${keyPrefix}-text-${nestedIdx}`
378447
const markdownOptionsForLevel = getAgentMarkdownOptions(indentLevel)
379448
const renderedContent = renderContentWithMarkdown(
380-
rawNestedContent,
449+
filteredNestedContent,
381450
isNestedStreamingText,
382451
markdownOptionsForLevel,
383452
)
@@ -517,8 +586,9 @@ export const MessageBlock = memo(
517586
const normalizedContent = isStreamingMessage
518587
? trimTrailingNewlines(content)
519588
: content.trim()
589+
const sanitizedContent = scrubPlanTags(normalizedContent)
520590
const displayContent = renderContentWithMarkdown(
521-
normalizedContent,
591+
sanitizedContent,
522592
isStreamingMessage,
523593
markdownOptions,
524594
)
@@ -536,13 +606,18 @@ export const MessageBlock = memo(
536606
const renderSingleBlock = (block: ContentBlock, idx: number) => {
537607
switch (block.type) {
538608
case 'text': {
609+
// Skip raw rendering for reasoning; grouped above into <Thinking>
610+
if (isReasoningTextBlock(block as any)) {
611+
return null
612+
}
539613
const isStreamingText = isLoading || !isComplete
540614
const rawContent = isStreamingText
541615
? trimTrailingNewlines(block.content)
542616
: block.content.trim()
617+
const filteredContent = scrubPlanTags(rawContent)
543618
const renderKey = `${messageId}-text-${idx}`
544619
const renderedContent = renderContentWithMarkdown(
545-
rawContent,
620+
filteredContent,
546621
isStreamingText,
547622
markdownOptions,
548623
)
@@ -622,6 +697,28 @@ export const MessageBlock = memo(
622697
const nodes: React.ReactNode[] = []
623698
for (let i = 0; i < sourceBlocks.length; ) {
624699
const block = sourceBlocks[i]
700+
// Handle reasoning text blocks
701+
if (isReasoningTextBlock(block as any)) {
702+
const start = i
703+
const reasoningBlocks: Extract<ContentBlock, { type: 'text' }>[] = []
704+
while (
705+
i < sourceBlocks.length &&
706+
isReasoningTextBlock(sourceBlocks[i] as any)
707+
) {
708+
reasoningBlocks.push(sourceBlocks[i] as any)
709+
i++
710+
}
711+
712+
const thinkingNode = renderThinkingBlock(
713+
reasoningBlocks,
714+
messageId,
715+
start,
716+
)
717+
if (thinkingNode) {
718+
nodes.push(thinkingNode)
719+
}
720+
continue
721+
}
625722
if (block.type === 'tool') {
626723
const start = i
627724
const group: Extract<ContentBlock, { type: 'tool' }>[] = []

cli/src/components/thinking.tsx

Lines changed: 101 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useTheme } from '../hooks/use-theme'
55
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
66
import { getLastNVisualLines } from '../utils/text-layout'
77

8+
const PREVIEW_LINE_COUNT = 3
9+
810
interface ThinkingProps {
911
content: string
1012
isCollapsed: boolean
@@ -13,64 +15,129 @@ interface ThinkingProps {
1315
}
1416

1517
export const Thinking = memo(
16-
({ content, isCollapsed, onToggle, availableWidth }: ThinkingProps): ReactNode => {
18+
({
19+
content,
20+
isCollapsed,
21+
onToggle,
22+
availableWidth,
23+
}: ThinkingProps): ReactNode => {
1724
const theme = useTheme()
1825
const { contentMaxWidth } = useTerminalDimensions()
1926

20-
const width = Math.max(10, Math.min((availableWidth ?? contentMaxWidth), 120))
21-
const { lines: lastLines, hasMore } = getLastNVisualLines(content, width, 3)
22-
const collapsedText = (hasMore ? '...\n' : '') + lastLines.join('\n')
27+
const width = Math.max(10, Math.min(availableWidth ?? contentMaxWidth, 120))
28+
// Normalize content to single line for consistent preview
29+
const normalizedContent = content.replace(/\n+/g, ' ').trim()
30+
const { lines, hasMore } = getLastNVisualLines(
31+
normalizedContent,
32+
width,
33+
PREVIEW_LINE_COUNT,
34+
)
35+
// Pad to exactly PREVIEW_LINE_COUNT lines for consistent height while streaming
36+
const previewLines = [...lines]
37+
while (previewLines.length < PREVIEW_LINE_COUNT) {
38+
previewLines.push('')
39+
}
2340

2441
return (
2542
<box
2643
style={{
2744
flexDirection: 'column',
2845
gap: 0,
29-
marginTop: 1,
30-
marginBottom: 1,
46+
marginTop: 0,
47+
marginBottom: 0,
3148
}}
49+
onMouseDown={onToggle}
3250
>
3351
<box
3452
style={{
3553
flexDirection: 'row',
3654
alignSelf: 'flex-start',
37-
backgroundColor: theme.muted,
38-
paddingLeft: 1,
39-
paddingRight: 1,
4055
}}
41-
onMouseDown={onToggle}
4256
>
43-
<text style={{ wrapMode: 'none' }}>
44-
<span fg={theme.foreground}>{isCollapsed ? '▸ ' : '▾ '}</span>
45-
<span fg={theme.foreground} attributes={TextAttributes.ITALIC}>
46-
Thinking
47-
</span>
57+
<text
58+
style={{ wrapMode: 'none', attributes: TextAttributes.BOLD }}
59+
fg={theme.foreground}
60+
>
61+
• Thinking
4862
</text>
4963
</box>
50-
{isCollapsed && collapsedText && (
51-
<box style={{ flexShrink: 1, marginTop: 0 }}>
52-
<text
64+
{isCollapsed ? (
65+
previewLines.length > 0 && (
66+
<box
5367
style={{
54-
wrapMode: 'word',
55-
fg: theme.muted,
68+
flexDirection: 'row',
69+
gap: 0,
70+
alignItems: 'stretch',
71+
marginTop: 0,
5672
}}
57-
attributes={TextAttributes.ITALIC}
5873
>
59-
{collapsedText}
60-
</text>
61-
</box>
62-
)}
63-
{!isCollapsed && (
64-
<box style={{ flexShrink: 1, marginTop: 0 }}>
65-
<text
74+
<box
75+
style={{
76+
width: 1,
77+
backgroundColor: theme.muted,
78+
marginTop: 0,
79+
marginBottom: 0,
80+
}}
81+
/>
82+
<box
83+
style={{
84+
paddingLeft: 1,
85+
flexGrow: 1,
86+
flexDirection: 'column',
87+
gap: 0,
88+
}}
89+
>
90+
{hasMore && (
91+
<text
92+
style={{
93+
wrapMode: 'none',
94+
fg: theme.muted,
95+
}}
96+
attributes={TextAttributes.ITALIC}
97+
>
98+
...
99+
</text>
100+
)}
101+
<text
102+
style={{
103+
wrapMode: 'word',
104+
fg: theme.muted,
105+
}}
106+
attributes={TextAttributes.ITALIC}
107+
>
108+
{previewLines.join(' ')}
109+
</text>
110+
</box>
111+
</box>
112+
)
113+
) : (
114+
<box
115+
style={{
116+
flexDirection: 'row',
117+
gap: 0,
118+
alignItems: 'stretch',
119+
marginTop: 0,
120+
}}
121+
>
122+
<box
66123
style={{
67-
wrapMode: 'word',
68-
fg: theme.muted,
124+
width: 1,
125+
backgroundColor: theme.muted,
126+
marginTop: 0,
127+
marginBottom: 0,
69128
}}
70-
attributes={TextAttributes.ITALIC}
71-
>
72-
{content}
73-
</text>
129+
/>
130+
<box style={{ paddingLeft: 1, flexGrow: 1 }}>
131+
<text
132+
style={{
133+
wrapMode: 'word',
134+
fg: theme.muted,
135+
}}
136+
attributes={TextAttributes.ITALIC}
137+
>
138+
{content}
139+
</text>
140+
</box>
74141
</box>
75142
)}
76143
</box>

0 commit comments

Comments
 (0)