Skip to content

Commit 863681c

Browse files
committed
Thinking block UX/refactor: show 5 line preview until next message
1 parent 1d9a7fe commit 863681c

File tree

10 files changed

+100
-71
lines changed

10 files changed

+100
-71
lines changed

cli/src/components/blocks/thinking-block.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const ThinkingBlock = memo(
3232
.join('')
3333
.trim()
3434

35-
const isCollapsed = firstBlock?.isCollapsed ?? true
35+
const thinkingCollapseState = firstBlock?.thinkingCollapseState ?? 'preview'
3636
const offset = isNested ? NESTED_WIDTH_OFFSET : WIDTH_OFFSET
3737
const availWidth = Math.max(10, availableWidth - offset)
3838

@@ -56,7 +56,7 @@ export const ThinkingBlock = memo(
5656
<box>
5757
<Thinking
5858
content={combinedContent}
59-
isCollapsed={isCollapsed}
59+
thinkingCollapseState={thinkingCollapseState}
6060
isThinkingComplete={isThinkingComplete}
6161
onToggle={handleToggle}
6262
availableWidth={availWidth}

cli/src/components/thinking.tsx

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

9+
import type { ThinkingCollapseState } from '../types/chat'
10+
911
const PREVIEW_LINE_COUNT = 5
1012

1113
interface ThinkingProps {
1214
content: string
13-
isCollapsed: boolean
15+
thinkingCollapseState: ThinkingCollapseState
1416
/** Whether the thinking has completed (streaming finished) */
1517
isThinkingComplete: boolean
1618
onToggle: () => void
@@ -20,7 +22,7 @@ interface ThinkingProps {
2022
export const Thinking = memo(
2123
({
2224
content,
23-
isCollapsed,
25+
thinkingCollapseState,
2426
isThinkingComplete,
2527
onToggle,
2628
availableWidth,
@@ -39,12 +41,14 @@ export const Thinking = memo(
3941
PREVIEW_LINE_COUNT,
4042
)
4143

42-
// Toggle indicator: show caret when complete, bullet when streaming
43-
const toggleIndicator = isThinkingComplete
44-
? isCollapsed
45-
? '▸ '
46-
: '▾ '
47-
: '• '
44+
const showFull = thinkingCollapseState === 'expanded'
45+
const showPreview = thinkingCollapseState === 'preview' && lines.length > 0
46+
47+
const toggleIndicator =
48+
!isThinkingComplete ? '• '
49+
: showFull ? '▾ '
50+
: showPreview ? '• '
51+
: '▸ '
4852

4953
return (
5054
<Button
@@ -60,24 +64,20 @@ export const Thinking = memo(
6064
<span>{toggleIndicator}</span>
6165
<span attributes={TextAttributes.BOLD}>Thinking</span>
6266
</text>
63-
{isCollapsed ? (
64-
// When complete: show no preview (just "▸ Thinking")
65-
// When streaming: show up to 5 lines preview
66-
!isThinkingComplete &&
67-
lines.length > 0 && (
68-
<box style={{ paddingLeft: 2 }}>
69-
<text
70-
style={{
71-
wrapMode: 'none',
72-
fg: theme.muted,
73-
}}
74-
attributes={TextAttributes.ITALIC}
75-
>
76-
{hasMore ? '...' + lines.join('\n') : lines.join('\n')}
77-
</text>
78-
</box>
79-
)
80-
) : (
67+
{showPreview && (
68+
<box style={{ paddingLeft: 2 }}>
69+
<text
70+
style={{
71+
wrapMode: 'none',
72+
fg: theme.muted,
73+
}}
74+
attributes={TextAttributes.ITALIC}
75+
>
76+
{hasMore ? '...' + lines.join('\n') : lines.join('\n')}
77+
</text>
78+
</box>
79+
)}
80+
{showFull && (
8181
<box style={{ paddingLeft: 2 }}>
8282
<text
8383
style={{

cli/src/hooks/use-chat-messages.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,11 @@ export function useChatMessages({
116116
// Handle thinking blocks - just match by thinkingId
117117
if (block.type === 'text' && block.thinkingId === id) {
118118
foundTarget = true
119-
const wasCollapsed = block.isCollapsed ?? false
119+
const isExpanded = block.thinkingCollapseState === 'expanded'
120120
return {
121121
...block,
122-
isCollapsed: !wasCollapsed,
123-
userOpened: wasCollapsed, // Mark as user-opened if expanding
122+
thinkingCollapseState: isExpanded ? 'preview' as const : 'expanded' as const,
123+
userOpened: !isExpanded, // Mark as user-opened if expanding
124124
}
125125
}
126126

cli/src/types/chat.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import type { ReactNode } from 'react'
1010

1111
export type ChatVariant = 'ai' | 'user' | 'agent' | 'error'
1212

13+
export type ThinkingCollapseState = 'expanded' | 'preview' | 'hidden'
14+
1315
export type TextContentBlock = {
1416
type: 'text'
1517
content: string
@@ -23,6 +25,7 @@ export type TextContentBlock = {
2325
userOpened?: boolean
2426
/** True if this is a reasoning block from a <think> tag that hasn't been closed yet */
2527
thinkingOpen?: boolean
28+
thinkingCollapseState?: ThinkingCollapseState
2629
}
2730
/** Renders dynamic React content. NOT serializable - don't use for persistent data. */
2831
export type HtmlContentBlock = {

cli/src/utils/__tests__/collapse-helpers.test.ts

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
AgentContentBlock,
1010
TextContentBlock,
1111
AgentListContentBlock,
12+
ThinkingCollapseState,
1213
} from '../../types/chat'
1314

1415
// Type helper for accessing isCollapsed/userOpened on any block type
@@ -64,13 +65,13 @@ const createAgentBlock = (
6465
// Helper to create thinking/text blocks with thinkingId
6566
const createThinkingBlock = (
6667
thinkingId: string,
67-
isCollapsed?: boolean,
68+
thinkingCollapseState?: ThinkingCollapseState,
6869
userOpened?: boolean,
6970
): ContentBlock => ({
7071
type: 'text',
7172
content: 'thinking content',
7273
thinkingId,
73-
isCollapsed,
74+
...(thinkingCollapseState !== undefined && { thinkingCollapseState }),
7475
userOpened,
7576
})
7677

@@ -195,14 +196,14 @@ describe('hasAnyExpandedBlocks', () => {
195196
describe('thinking blocks', () => {
196197
test('returns true when thinking block is expanded', () => {
197198
const messages = [
198-
createMessage('1', 'ai', [createThinkingBlock('think-1', false)]),
199+
createMessage('1', 'ai', [createThinkingBlock('think-1', 'expanded')]),
199200
]
200201
expect(hasAnyExpandedBlocks(messages)).toBe(true)
201202
})
202203

203204
test('returns false when thinking block is collapsed', () => {
204205
const messages = [
205-
createMessage('1', 'ai', [createThinkingBlock('think-1', true)]),
206+
createMessage('1', 'ai', [createThinkingBlock('think-1', 'hidden')]),
206207
]
207208
expect(hasAnyExpandedBlocks(messages)).toBe(false)
208209
})
@@ -463,22 +464,22 @@ describe('setAllBlocksCollapsedState', () => {
463464
describe('thinking blocks', () => {
464465
test('collapses thinking blocks', () => {
465466
const messages = [
466-
createMessage('1', 'ai', [createThinkingBlock('think-1', false)]),
467+
createMessage('1', 'ai', [createThinkingBlock('think-1', 'expanded')]),
467468
]
468469
const result = setAllBlocksCollapsedState(messages, true)
469470

470-
const block = result[0]?.blocks?.[0] as CollapsibleBlock
471-
expect(block?.isCollapsed).toBe(true)
471+
const block = result[0]?.blocks?.[0] as TextContentBlock
472+
expect(block?.thinkingCollapseState).toBe('hidden')
472473
})
473474

474475
test('expands thinking blocks and sets userOpened', () => {
475476
const messages = [
476-
createMessage('1', 'ai', [createThinkingBlock('think-1', true)]),
477+
createMessage('1', 'ai', [createThinkingBlock('think-1', 'hidden')]),
477478
]
478479
const result = setAllBlocksCollapsedState(messages, false)
479480

480-
const block = result[0]?.blocks?.[0] as CollapsibleBlock
481-
expect(block?.isCollapsed).toBe(false)
481+
const block = result[0]?.blocks?.[0] as TextContentBlock
482+
expect(block?.thinkingCollapseState).toBe('expanded')
482483
expect(block?.userOpened).toBe(true)
483484
})
484485

@@ -522,7 +523,7 @@ describe('setAllBlocksCollapsedState', () => {
522523
createMessage('1', 'ai', [
523524
createToolBlock('tool-1', false),
524525
createAgentBlock('agent-1', false),
525-
createThinkingBlock('think-1', false),
526+
createThinkingBlock('think-1', 'expanded'),
526527
createAgentListBlock('list-1', false),
527528
createTextBlock('regular text'),
528529
]),
@@ -532,7 +533,7 @@ describe('setAllBlocksCollapsedState', () => {
532533
const blocks = result[0]?.blocks as CollapsibleBlock[]
533534
expect(blocks[0]?.isCollapsed).toBe(true) // tool
534535
expect(blocks[1]?.isCollapsed).toBe(true) // agent
535-
expect(blocks[2]?.isCollapsed).toBe(true) // thinking
536+
expect((blocks[2] as TextContentBlock)?.thinkingCollapseState).toBe('hidden') // thinking
536537
expect(blocks[3]?.isCollapsed).toBe(true) // agent-list
537538
expect((blocks[4] as TextContentBlock)?.isCollapsed).toBeUndefined() // text (not collapsible)
538539
})
@@ -542,7 +543,7 @@ describe('setAllBlocksCollapsedState', () => {
542543
createMessage('1', 'ai', [
543544
createToolBlock('tool-1', true),
544545
createAgentBlock('agent-1', true),
545-
createThinkingBlock('think-1', true),
546+
createThinkingBlock('think-1', 'hidden'),
546547
createAgentListBlock('list-1', true),
547548
]),
548549
]
@@ -553,8 +554,8 @@ describe('setAllBlocksCollapsedState', () => {
553554
expect(blocks[0]?.userOpened).toBe(true)
554555
expect(blocks[1]?.isCollapsed).toBe(false)
555556
expect(blocks[1]?.userOpened).toBe(true)
556-
expect(blocks[2]?.isCollapsed).toBe(false)
557-
expect(blocks[2]?.userOpened).toBe(true)
557+
expect((blocks[2] as TextContentBlock)?.thinkingCollapseState).toBe('expanded')
558+
expect((blocks[2] as TextContentBlock)?.userOpened).toBe(true)
558559
expect(blocks[3]?.isCollapsed).toBe(false)
559560
expect(blocks[3]?.userOpened).toBe(true)
560561
})
@@ -746,27 +747,27 @@ describe('toggle-all edge cases', () => {
746747

747748
test('setAllBlocksCollapsedState: collapses both parent and nested blocks', () => {
748749
const nestedBlocks = [
749-
createToolBlock('tool-1', false), // expanded
750-
createThinkingBlock('think-1', false), // expanded
750+
createToolBlock('tool-1', false),
751+
createThinkingBlock('think-1', 'expanded'),
751752
]
752753
const messages = [
753-
createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, nestedBlocks)]), // expanded parent
754+
createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, nestedBlocks)]),
754755
]
755756
const result = setAllBlocksCollapsedState(messages, true)
756757

757758
const agentBlock = result[0]?.blocks?.[0] as AgentContentBlock
758759
expect(agentBlock?.isCollapsed).toBe(true)
759760
expect((agentBlock?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true)
760-
expect((agentBlock?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(true)
761+
expect((agentBlock?.blocks?.[1] as TextContentBlock)?.thinkingCollapseState).toBe('hidden')
761762
})
762763

763764
test('setAllBlocksCollapsedState: expands both parent and nested blocks', () => {
764765
const nestedBlocks = [
765-
createToolBlock('tool-1', true), // collapsed
766-
createThinkingBlock('think-1', true), // collapsed
766+
createToolBlock('tool-1', true),
767+
createThinkingBlock('think-1', 'hidden'),
767768
]
768769
const messages = [
769-
createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), // collapsed parent
770+
createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]),
770771
]
771772
const result = setAllBlocksCollapsedState(messages, false)
772773

@@ -775,8 +776,8 @@ describe('toggle-all edge cases', () => {
775776
expect(agentBlock?.userOpened).toBe(true)
776777
expect((agentBlock?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(false)
777778
expect((agentBlock?.blocks?.[0] as CollapsibleBlock)?.userOpened).toBe(true)
778-
expect((agentBlock?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(false)
779-
expect((agentBlock?.blocks?.[1] as CollapsibleBlock)?.userOpened).toBe(true)
779+
expect((agentBlock?.blocks?.[1] as TextContentBlock)?.thinkingCollapseState).toBe('expanded')
780+
expect((agentBlock?.blocks?.[1] as TextContentBlock)?.userOpened).toBe(true)
780781
})
781782
})
782783

@@ -1087,7 +1088,7 @@ describe('toggle-all edge cases', () => {
10871088
test('nested agent blocks with all types of collapsible blocks', () => {
10881089
const deepBlocks = [
10891090
createToolBlock('deep-tool', false),
1090-
createThinkingBlock('deep-think', false),
1091+
createThinkingBlock('deep-think', 'expanded'),
10911092
createAgentListBlock('deep-list', false),
10921093
]
10931094
const messages = [
@@ -1101,7 +1102,7 @@ describe('toggle-all edge cases', () => {
11011102
const outerAgent = result[0]?.blocks?.[0] as AgentContentBlock
11021103
expect(outerAgent?.isCollapsed).toBe(true)
11031104
expect((outerAgent?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true)
1104-
expect((outerAgent?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(true)
1105+
expect((outerAgent?.blocks?.[1] as TextContentBlock)?.thinkingCollapseState).toBe('hidden')
11051106
expect((outerAgent?.blocks?.[2] as CollapsibleBlock)?.isCollapsed).toBe(true)
11061107
})
11071108
})

cli/src/utils/__tests__/message-block-helpers.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ describe('autoCollapseBlocks', () => {
125125
{ type: 'text', content: 'thinking', thinkingId: 'think-1' },
126126
]
127127
const result = autoCollapseBlocks(blocks)
128-
expect(result[0]).toHaveProperty('isCollapsed', true)
128+
expect(result[0]).toHaveProperty('thinkingCollapseState', 'hidden')
129129
})
130130

131131
test('preserves user-opened text blocks', () => {
@@ -394,7 +394,7 @@ describe('appendInterruptionNotice', () => {
394394
status: 'running',
395395
thinkingId: 'think-1',
396396
userOpened: true,
397-
isCollapsed: true,
397+
thinkingCollapseState: 'hidden',
398398
},
399399
]
400400
const result = appendInterruptionNotice(blocks)
@@ -403,7 +403,7 @@ describe('appendInterruptionNotice', () => {
403403
status: 'running',
404404
thinkingId: 'think-1',
405405
userOpened: true,
406-
isCollapsed: true,
406+
thinkingCollapseState: 'hidden',
407407
content: 'Hello\n\n[response interrupted]',
408408
})
409409
})

cli/src/utils/__tests__/send-message-helpers.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ describe('autoCollapseBlocks', () => {
252252
]
253253

254254
const result = autoCollapseBlocks(blocks)
255-
expect((result[0] as TextContentBlock).isCollapsed).toBe(true)
255+
expect((result[0] as TextContentBlock).thinkingCollapseState).toBe('hidden')
256256
})
257257

258258
test('does not collapse user-opened blocks', () => {
@@ -441,7 +441,7 @@ describe('appendTextToRootStream', () => {
441441

442442
expect(result).toHaveLength(2)
443443
expect((result[1] as TextContentBlock).textType).toBe('reasoning')
444-
expect((result[1] as TextContentBlock).isCollapsed).toBe(true)
444+
expect((result[1] as TextContentBlock).thinkingCollapseState).toBe('preview')
445445
})
446446

447447
test('returns original blocks for empty text', () => {
@@ -912,7 +912,7 @@ describe('appendTextToAgentBlock with native reasoning', () => {
912912
expect(agentBlock.blocks).toHaveLength(1)
913913
expect((agentBlock.blocks![0] as TextContentBlock).textType).toBe('reasoning')
914914
expect((agentBlock.blocks![0] as TextContentBlock).content).toBe('Thinking...')
915-
expect((agentBlock.blocks![0] as TextContentBlock).isCollapsed).toBe(true)
915+
expect((agentBlock.blocks![0] as TextContentBlock).thinkingCollapseState).toBe('preview')
916916
// Native reasoning has thinkingOpen undefined
917917
expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBeUndefined()
918918
})

cli/src/utils/block-operations.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ const createReasoningBlock = (
113113
type: 'text',
114114
content,
115115
textType: 'reasoning',
116-
isCollapsed: true,
116+
thinkingCollapseState: 'preview',
117117
thinkingOpen,
118118
thinkingId,
119119
})
@@ -315,7 +315,7 @@ const appendNativeReasoningToBlocks = (
315315
type: 'text',
316316
content: text,
317317
textType: 'reasoning',
318-
isCollapsed: true,
318+
thinkingCollapseState: 'preview',
319319
thinkingId: generateThinkingId(),
320320
}
321321

@@ -419,7 +419,7 @@ export const appendTextToRootStream = (
419419
type: 'text',
420420
content: delta.text,
421421
textType: 'reasoning',
422-
isCollapsed: true,
422+
thinkingCollapseState: 'preview',
423423
thinkingId: generateThinkingId(),
424424
}
425425

0 commit comments

Comments
 (0)