diff --git a/cli/src/components/__tests__/thinking.test.tsx b/cli/src/components/__tests__/thinking.test.tsx new file mode 100644 index 0000000000..e20a5273c3 --- /dev/null +++ b/cli/src/components/__tests__/thinking.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + 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() + } + }) +}) diff --git a/cli/src/components/blocks/thinking-block.tsx b/cli/src/components/blocks/thinking-block.tsx index a29f5ff2c2..cdb3f569a1 100644 --- a/cli/src/components/blocks/thinking-block.tsx +++ b/cli/src/components/blocks/thinking-block.tsx @@ -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 diff --git a/cli/src/components/thinking.tsx b/cli/src/components/thinking.tsx index 6fbf28db50..6c77059350 100644 --- a/cli/src/components/thinking.tsx +++ b/cli/src/components/thinking.tsx @@ -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 ? '• '