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 (