From 4e69634c65017c1e54fddbf9fb81bf990d39e936 Mon Sep 17 00:00:00 2001 From: Mikey Date: Wed, 2 Sep 2026 16:52:13 -0700 Subject: [PATCH 1/2] Optimize TerminalCommandDisplay visual wrapping with early-exit preview --- .../terminal-command-display.test.tsx | 102 ++++++++++++++++++ .../components/terminal-command-display.tsx | 78 +++++++++----- 2 files changed, 156 insertions(+), 24 deletions(-) create mode 100644 cli/src/components/__tests__/terminal-command-display.test.tsx 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..64f9d27c4e --- /dev/null +++ b/cli/src/components/__tests__/terminal-command-display.test.tsx @@ -0,0 +1,102 @@ +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() + } + }) +}) diff --git a/cli/src/components/terminal-command-display.tsx b/cli/src/components/terminal-command-display.tsx index 1f72fe8e2c..31e169dc00 100644 --- a/cli/src/components/terminal-command-display.tsx +++ b/cli/src/components/terminal-command-display.tsx @@ -82,38 +82,68 @@ 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) { + let totalVisual = 0 + for (const line of allLines) { + if (line.length === 0) continue + totalVisual += + line.length <= width ? 1 : Math.max(1, Math.ceil(line.length / width)) + } + hasMoreLines = totalVisual > maxLines + hiddenLinesCount = Math.max(0, totalVisual - maxLines) 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 hadMoreInProcessed = false - 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++ + continue + } + const { lines: wrapped } = getLastNVisualLines(line, width, Infinity) + for (const wl of wrapped) { + if (displayLines.length < maxLines) { + displayLines.push(wl) + } else { + hadMoreInProcessed = true + break + } } - if (count >= maxLines) break + linesProcessed++ + if (hadMoreInProcessed || displayLines.length >= maxLines) break } - displayOutput = displayLines.join('\n') + hasMoreLines = hadMoreInProcessed || linesProcessed < allLines.length + + if (!hasMoreLines) { + displayOutput = output + hiddenLinesCount = 0 + } else { + displayOutput = displayLines.slice(0, maxLines).join('\n') + + // Estimate remaining visual lines efficiently without regex word-splitting on off-screen lines + let remainingVisualLines = 0 + for (let i = linesProcessed; i < allLines.length; i++) { + const line = allLines[i] + if (line.length === 0) continue + if (line.length <= width) { + remainingVisualLines++ + } else { + remainingVisualLines += Math.max(1, Math.ceil(line.length / width)) + } + } + + const totalVisualLines = + displayLines.length + (hadMoreInProcessed ? 1 : 0) + remainingVisualLines + hiddenLinesCount = Math.max(1, totalVisualLines - maxLines) + } } return ( From 36f82c7ed9c2da86aa432363b28e1c3bbffc72e0 Mon Sep 17 00:00:00 2001 From: Mikey Date: Thu, 3 Sep 2026 09:50:43 -0700 Subject: [PATCH 2/2] Fix blank-line handling and line counting in TerminalCommandDisplay visual wrapping --- .../terminal-command-display.test.tsx | 106 +++++++++++++++++- .../components/terminal-command-display.tsx | 64 +++++++---- 2 files changed, 148 insertions(+), 22 deletions(-) diff --git a/cli/src/components/__tests__/terminal-command-display.test.tsx b/cli/src/components/__tests__/terminal-command-display.test.tsx index 64f9d27c4e..29b24bd856 100644 --- a/cli/src/components/__tests__/terminal-command-display.test.tsx +++ b/cli/src/components/__tests__/terminal-command-display.test.tsx @@ -43,7 +43,10 @@ describe('TerminalCommandDisplay', () => { 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') + const manyLines = Array.from( + { length: 20 }, + (_, i) => `log line ${i + 1}`, + ).join('\n') flushSync(() => { root.render( @@ -99,4 +102,105 @@ describe('TerminalCommandDisplay', () => { 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 31e169dc00..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}) )} @@ -87,40 +88,52 @@ export const TerminalCommandDisplay = ({ let displayOutput: string if (isExpanded) { - let totalVisual = 0 - for (const line of allLines) { - if (line.length === 0) continue - totalVisual += - line.length <= width ? 1 : Math.max(1, Math.ceil(line.length / width)) + 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 } - hasMoreLines = totalVisual > maxLines - hiddenLinesCount = Math.max(0, totalVisual - maxLines) + hiddenLinesCount = 0 displayOutput = output } else { // Only wrap lines until maxLines visual lines are gathered const displayLines: string[] = [] let linesProcessed = 0 - let hadMoreInProcessed = false + let excessInProcessedLine = 0 for (const line of allLines) { if (line.length === 0) { linesProcessed++ + displayLines.push('') + if (displayLines.length >= maxLines) break continue } const { lines: wrapped } = getLastNVisualLines(line, width, Infinity) - for (const wl of wrapped) { + let brokeEarly = false + for (let i = 0; i < wrapped.length; i++) { if (displayLines.length < maxLines) { - displayLines.push(wl) + displayLines.push(wrapped[i]) } else { - hadMoreInProcessed = true + excessInProcessedLine = wrapped.length - i + brokeEarly = true break } } linesProcessed++ - if (hadMoreInProcessed || displayLines.length >= maxLines) break + if (brokeEarly || displayLines.length >= maxLines) break } - hasMoreLines = hadMoreInProcessed || linesProcessed < allLines.length + hasMoreLines = excessInProcessedLine > 0 || linesProcessed < allLines.length if (!hasMoreLines) { displayOutput = output @@ -128,20 +141,29 @@ export const TerminalCommandDisplay = ({ } else { displayOutput = displayLines.slice(0, maxLines).join('\n') - // Estimate remaining visual lines efficiently without regex word-splitting on off-screen lines - let remainingVisualLines = 0 + 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) continue - if (line.length <= width) { + 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 += Math.max(1, Math.ceil(line.length / width)) + remainingVisualLines += + line.length <= width + ? 1 + : Math.max(1, Math.ceil(line.length / width)) } } - const totalVisualLines = - displayLines.length + (hadMoreInProcessed ? 1 : 0) + remainingVisualLines + const totalVisualLines = displayLines.length + remainingVisualLines hiddenLinesCount = Math.max(1, totalVisualLines - maxLines) } }