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
206 changes: 206 additions & 0 deletions cli/src/components/__tests__/terminal-command-display.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TerminalCommandDisplay
command="ls"
output="file1.txt\nfile2.txt"
expandable={true}
maxVisibleLines={5}
/>,
)
})

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(
<TerminalCommandDisplay
command="cat logs.txt"
output={manyLines}
expandable={true}
maxVisibleLines={5}
/>,
)
})

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(
<TerminalCommandDisplay
command="echo long"
output={longLine}
expandable={true}
maxVisibleLines={5}
availableWidth={40}
/>,
)
})

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(
<TerminalCommandDisplay
command="test"
output={output}
expandable={true}
maxVisibleLines={5}
/>,
)
})

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(
<TerminalCommandDisplay
command="git log"
output={output}
expandable={true}
maxVisibleLines={3}
/>,
)
})

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(
<TerminalCommandDisplay
command="wrap-test"
output={output}
expandable={true}
maxVisibleLines={3}
availableWidth={20}
/>,
)
})

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()
}
})
})
102 changes: 77 additions & 25 deletions cli/src/components/terminal-command-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export const TerminalCommandDisplay = ({
</span>
{timeoutLabel && (
<span fg={theme.muted} attributes={TextAttributes.DIM}>
{' '}({timeoutLabel})
{' '}
({timeoutLabel})
</span>
)}
</text>
Expand All @@ -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 (
Expand Down
Loading