Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions cli/src/components/__tests__/thinking.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { createTestRenderer } from '@opentui/core/testing'
import { createRoot, flushSync } from '@opentui/react'
import React from 'react'

import { Thinking } from '../thinking'
import { initializeThemeStore } from '../../hooks/use-theme'

beforeAll(() => {
initializeThemeStore()
})

describe('Thinking component', () => {
const content = 'Line 1 of reasoning.\nLine 2 of reasoning.\nLine 3 of reasoning.'

test('renders in preview state with preview lines', async () => {
const setup = await createTestRenderer({ width: 80, height: 10 })
const root = createRoot(setup.renderer)
flushSync(() => {
root.render(
<Thinking
content={content}
thinkingCollapseState="preview"
isThinkingComplete={true}
onToggle={() => {}}
availableWidth={80}
/>,
)
})

try {
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain('Thinking')
expect(frame).toContain('Line')
} finally {
flushSync(() => root.unmount())
setup.renderer.destroy()
}
})

test('renders in hidden state without preview text', async () => {
const setup = await createTestRenderer({ width: 80, height: 10 })
const root = createRoot(setup.renderer)
flushSync(() => {
root.render(
<Thinking
content={content}
thinkingCollapseState="hidden"
isThinkingComplete={true}
onToggle={() => {}}
availableWidth={80}
/>,
)
})

try {
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain('Thinking')
expect(frame).toContain('▸')
expect(frame).not.toContain('Line 1 of reasoning.')
} finally {
flushSync(() => root.unmount())
setup.renderer.destroy()
}
})

test('renders in expanded state with expanded text', async () => {
const setup = await createTestRenderer({ width: 80, height: 10 })
const root = createRoot(setup.renderer)
flushSync(() => {
root.render(
<Thinking
content={content}
thinkingCollapseState="expanded"
isThinkingComplete={true}
onToggle={() => {}}
availableWidth={80}
/>,
)
})

try {
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain('Thinking')
expect(frame).toContain('▾')
expect(frame).toContain('Line 1 of reasoning.')
} finally {
flushSync(() => root.unmount())
setup.renderer.destroy()
}
})
})
11 changes: 7 additions & 4 deletions cli/src/components/blocks/thinking-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ export const ThinkingBlock = memo(
}: ThinkingBlockProps) => {
const firstBlock = blocks[0]
const thinkingId = firstBlock?.thinkingId
const combinedContent = blocks
.map((b) => b.content)
.join('')
.trim()
const combinedContent =
blocks.length === 1
? (firstBlock?.content ?? '').trim()
: blocks
.map((b) => b.content)
.join('')
.trim()

const thinkingCollapseState = firstBlock?.thinkingCollapseState ?? 'preview'
const offset = isNested ? NESTED_WIDTH_OFFSET : WIDTH_OFFSET
Expand Down
37 changes: 24 additions & 13 deletions cli/src/components/thinking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,32 @@ export const Thinking = memo(
)
}

const width = Math.max(10, availableWidth ?? contentMaxWidth)
// Normalize content to single line for consistent preview (but preserve in expanded mode)
const normalizedContent = content.replace(/\n+/g, ' ').trim()
// Account for "..." prefix (3 chars) when calculating line widths
const effectiveWidth = width - 3
const { lines, hasMore } = getLastNVisualLines(
normalizedContent,
effectiveWidth,
PREVIEW_LINE_COUNT,
)
const showFull = thinkingCollapseState === 'expanded'
const isPreviewCandidate = thinkingCollapseState === 'preview'

let lines: string[] = []
let hasMore = false
if (isPreviewCandidate) {
const width = Math.max(10, availableWidth ?? contentMaxWidth)
// Normalize content to single line for consistent preview (but preserve in expanded mode)
const normalizedContent = content.replace(/\n+/g, ' ').trim()
// Account for "..." prefix (3 chars) when calculating line widths
const effectiveWidth = width - 3
const result = getLastNVisualLines(
normalizedContent,
effectiveWidth,
PREVIEW_LINE_COUNT,
)
lines = result.lines
hasMore = result.hasMore
}

// In expanded mode, preserve original line breaks for proper markdown rendering
const expandedContent = content.replace(/\n\n+/g, '\n\n').trim()
const expandedContent = showFull
? content.replace(/\n\n+/g, '\n\n').trim()
: ''

const showFull = thinkingCollapseState === 'expanded'
const showPreview = thinkingCollapseState === 'preview' && lines.length > 0
const showPreview = isPreviewCandidate && lines.length > 0

const toggleIndicator =
!isThinkingComplete ? '• '
Expand Down
Loading