diff --git a/cli/src/components/__tests__/terminal-command-display.test.tsx b/cli/src/components/__tests__/terminal-command-display.test.tsx new file mode 100644 index 0000000000..29b24bd856 --- /dev/null +++ b/cli/src/components/__tests__/terminal-command-display.test.tsx @@ -0,0 +1,206 @@ +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 { TerminalCommandDisplay } from '../terminal-command-display' +import { initializeThemeStore } from '../../hooks/use-theme' + +beforeAll(() => { + initializeThemeStore() +}) + +describe('TerminalCommandDisplay', () => { + test('renders short output without truncation or show more button', async () => { + const setup = await createTestRenderer({ width: 80, height: 10 }) + const root = createRoot(setup.renderer) + + flushSync(() => { + root.render( + , + ) + }) + + try { + await setup.renderOnce() + const frame = setup.captureCharFrame() + expect(frame).toContain('$ ls') + expect(frame).toContain('file1.txt') + expect(frame).toContain('file2.txt') + expect(frame).not.toContain('Show') + } finally { + flushSync(() => root.unmount()) + setup.renderer.destroy() + } + }) + + test('truncates output exceeding maxVisibleLines and displays show more button', async () => { + const setup = await createTestRenderer({ width: 80, height: 15 }) + const root = createRoot(setup.renderer) + + const manyLines = Array.from( + { length: 20 }, + (_, i) => `log line ${i + 1}`, + ).join('\n') + + flushSync(() => { + root.render( + , + ) + }) + + try { + await setup.renderOnce() + const frame = setup.captureCharFrame() + expect(frame).toContain('$ cat logs.txt') + expect(frame).toContain('log line 1') + expect(frame).toContain('log line 5') + expect(frame).not.toContain('log line 10') + expect(frame).toContain('Show 15 more lines') + } finally { + flushSync(() => root.unmount()) + setup.renderer.destroy() + } + }) + + test('handles output where a single long line wraps', async () => { + const setup = await createTestRenderer({ width: 40, height: 15 }) + const root = createRoot(setup.renderer) + + // A single line of 280 chars wraps into 7 visual lines on 40-col terminal + const longLine = 'a'.repeat(280) + + flushSync(() => { + root.render( + , + ) + }) + + try { + await setup.renderOnce() + const frame = setup.captureCharFrame() + expect(frame).toContain('$ echo long') + expect(frame).toContain('Show') + } finally { + flushSync(() => root.unmount()) + setup.renderer.destroy() + } + }) + + test('preserves interstitial blank lines in preview and counts them toward maxVisibleLines', async () => { + const setup = await createTestRenderer({ width: 80, height: 15 }) + const root = createRoot(setup.renderer) + + // 5 visual lines: 'header', '', 'middle', '', 'footer' + // followed by 2 off-screen lines: 'extra1', 'extra2' + const output = 'header\n\nmiddle\n\nfooter\nextra1\nextra2' + + flushSync(() => { + root.render( + , + ) + }) + + try { + await setup.renderOnce() + const frame = setup.captureCharFrame() + expect(frame).toContain('$ test') + expect(frame).toContain('header') + expect(frame).toContain('middle') + expect(frame).toContain('footer') + expect(frame).not.toContain('extra1') + expect(frame).not.toContain('extra2') + expect(frame).toContain('Show 2 more lines') + } finally { + flushSync(() => root.unmount()) + setup.renderer.destroy() + } + }) + + test('counts off-screen blank lines in hiddenLinesCount accurately', async () => { + const setup = await createTestRenderer({ width: 80, height: 15 }) + const root = createRoot(setup.renderer) + + // 3 visible lines, then 5 off-screen visual lines containing blank lines + const output = 'line 1\nline 2\nline 3\n\n\nline 6\n\nline 8' + + flushSync(() => { + root.render( + , + ) + }) + + try { + await setup.renderOnce() + const frame = setup.captureCharFrame() + expect(frame).toContain('$ git log') + expect(frame).toContain('line 1') + expect(frame).toContain('line 3') + expect(frame).not.toContain('line 6') + // Total visual lines: 8. Max visible: 3. Hidden: 5. + expect(frame).toContain('Show 5 more lines') + } finally { + flushSync(() => root.unmount()) + setup.renderer.destroy() + } + }) + + test('accurately counts remaining visual lines when a line wraps across the preview boundary', async () => { + const setup = await createTestRenderer({ width: 20, height: 15 }) + const root = createRoot(setup.renderer) + + // line 1: 1 visual line + // line 2: 100 chars on width 20 wraps to 5 visual lines (total visual lines = 6) + // With maxVisibleLines = 3, line 1 takes 1 and line 2 takes 2. 3 remaining hidden lines. + const output = 'start\n' + 'a'.repeat(100) + + flushSync(() => { + root.render( + , + ) + }) + + try { + await setup.renderOnce() + const frame = setup.captureCharFrame() + expect(frame).toContain('$ wrap-test') + expect(frame).toContain('start') + expect(frame).toContain('Show 3 more lines') + } finally { + flushSync(() => root.unmount()) + setup.renderer.destroy() + } + }) +}) diff --git a/cli/src/components/terminal-command-display.tsx b/cli/src/components/terminal-command-display.tsx index 1f72fe8e2c..8a76f9f8a9 100644 --- a/cli/src/components/terminal-command-display.tsx +++ b/cli/src/components/terminal-command-display.tsx @@ -61,7 +61,8 @@ export const TerminalCommandDisplay = ({ {timeoutLabel && ( - {' '}({timeoutLabel}) + {' '} + ({timeoutLabel}) )} @@ -82,38 +83,89 @@ export const TerminalCommandDisplay = ({ const width = Math.max(10, availableWidth ?? separatorWidth) const allLines = output.split('\n') - // Calculate total visual lines across all output lines - let totalVisualLines = 0 - const visualLinesByOriginalLine: string[][] = [] - - for (const line of allLines) { - const { lines: wrappedLines } = getLastNVisualLines(line, width, Infinity) - visualLinesByOriginalLine.push(wrappedLines) - totalVisualLines += wrappedLines.length - } - - const hasMoreLines = totalVisualLines > maxLines - const hiddenLinesCount = totalVisualLines - maxLines - - // Build display output + let hasMoreLines = false + let hiddenLinesCount = 0 let displayOutput: string - if (isExpanded || !hasMoreLines) { + + if (isExpanded) { + if (allLines.length > maxLines) { + hasMoreLines = true + } else { + let totalVisual = 0 + for (const line of allLines) { + if (line.length === 0) { + totalVisual++ + } else if (line.length <= width) { + totalVisual++ + } else { + totalVisual += Math.max(1, Math.ceil(line.length / width)) + } + } + hasMoreLines = totalVisual > maxLines + } + hiddenLinesCount = 0 displayOutput = output } else { - // Take first N visual lines + // Only wrap lines until maxLines visual lines are gathered const displayLines: string[] = [] - let count = 0 + let linesProcessed = 0 + let excessInProcessedLine = 0 - for (const wrappedLines of visualLinesByOriginalLine) { - for (const line of wrappedLines) { - if (count >= maxLines) break - displayLines.push(line) - count++ + for (const line of allLines) { + if (line.length === 0) { + linesProcessed++ + displayLines.push('') + if (displayLines.length >= maxLines) break + continue } - if (count >= maxLines) break + const { lines: wrapped } = getLastNVisualLines(line, width, Infinity) + let brokeEarly = false + for (let i = 0; i < wrapped.length; i++) { + if (displayLines.length < maxLines) { + displayLines.push(wrapped[i]) + } else { + excessInProcessedLine = wrapped.length - i + brokeEarly = true + break + } + } + linesProcessed++ + if (brokeEarly || displayLines.length >= maxLines) break } - displayOutput = displayLines.join('\n') + hasMoreLines = excessInProcessedLine > 0 || linesProcessed < allLines.length + + if (!hasMoreLines) { + displayOutput = output + hiddenLinesCount = 0 + } else { + displayOutput = displayLines.slice(0, maxLines).join('\n') + + let remainingVisualLines = excessInProcessedLine + const EXACT_WRAP_LINE_BUDGET = 50 + let exactLinesCount = 0 + + for (let i = linesProcessed; i < allLines.length; i++) { + const line = allLines[i] + if (line.length === 0) { + remainingVisualLines++ + continue + } + if (exactLinesCount < EXACT_WRAP_LINE_BUDGET) { + const { lines: wrapped } = getLastNVisualLines(line, width, Infinity) + remainingVisualLines += wrapped.length + exactLinesCount++ + } else { + remainingVisualLines += + line.length <= width + ? 1 + : Math.max(1, Math.ceil(line.length / width)) + } + } + + const totalVisualLines = displayLines.length + remainingVisualLines + hiddenLinesCount = Math.max(1, totalVisualLines - maxLines) + } } return (