/ utils / terminal.ts
terminal.ts
  1  import chalk from 'chalk'
  2  import { ctrlOToExpand } from '../components/CtrlOToExpand.js'
  3  import { stringWidth } from '../ink/stringWidth.js'
  4  import sliceAnsi from './sliceAnsi.js'
  5  
  6  // Text rendering utilities for terminal display
  7  const MAX_LINES_TO_SHOW = 3
  8  // Account for MessageResponse prefix ("  ⎿ " = 5 chars) + parent width
  9  // reduction (columns - 5 in tool result rendering)
 10  const PADDING_TO_PREVENT_OVERFLOW = 10
 11  
 12  /**
 13   * Inserts newlines in a string to wrap it at the specified width.
 14   * Uses ANSI-aware slicing to avoid splitting escape sequences.
 15   * @param text The text to wrap.
 16   * @param wrapWidth The width at which to wrap lines (in visible characters).
 17   * @returns The wrapped text.
 18   */
 19  function wrapText(
 20    text: string,
 21    wrapWidth: number,
 22  ): { aboveTheFold: string; remainingLines: number } {
 23    const lines = text.split('\n')
 24    const wrappedLines: string[] = []
 25  
 26    for (const line of lines) {
 27      const visibleWidth = stringWidth(line)
 28      if (visibleWidth <= wrapWidth) {
 29        wrappedLines.push(line.trimEnd())
 30      } else {
 31        // Break long lines into chunks of wrapWidth visible characters
 32        // using ANSI-aware slicing to preserve escape sequences
 33        let position = 0
 34        while (position < visibleWidth) {
 35          const chunk = sliceAnsi(line, position, position + wrapWidth)
 36          wrappedLines.push(chunk.trimEnd())
 37          position += wrapWidth
 38        }
 39      }
 40    }
 41  
 42    const remainingLines = wrappedLines.length - MAX_LINES_TO_SHOW
 43  
 44    // If there's only 1 line after the fold, show it directly
 45    // instead of showing "... +1 line (ctrl+o to expand)"
 46    if (remainingLines === 1) {
 47      return {
 48        aboveTheFold: wrappedLines
 49          .slice(0, MAX_LINES_TO_SHOW + 1)
 50          .join('\n')
 51          .trimEnd(),
 52        remainingLines: 0, // All lines are shown, nothing remaining
 53      }
 54    }
 55  
 56    // Otherwise show the standard MAX_LINES_TO_SHOW
 57    return {
 58      aboveTheFold: wrappedLines.slice(0, MAX_LINES_TO_SHOW).join('\n').trimEnd(),
 59      remainingLines: Math.max(0, remainingLines),
 60    }
 61  }
 62  
 63  /**
 64   * Renders the content with line-based truncation for terminal display.
 65   * If the content exceeds the maximum number of lines, it truncates the content
 66   * and adds a message indicating the number of additional lines.
 67   * @param content The content to render.
 68   * @param terminalWidth Terminal width for wrapping lines.
 69   * @returns The rendered content with truncation if needed.
 70   */
 71  export function renderTruncatedContent(
 72    content: string,
 73    terminalWidth: number,
 74    suppressExpandHint = false,
 75  ): string {
 76    const trimmedContent = content.trimEnd()
 77    if (!trimmedContent) {
 78      return ''
 79    }
 80  
 81    const wrapWidth = Math.max(terminalWidth - PADDING_TO_PREVENT_OVERFLOW, 10)
 82  
 83    // Only process enough content for the visible lines. Avoids O(n) wrapping
 84    // on huge outputs (e.g. 64MB binary dumps that cause 382K-row screens).
 85    const maxChars = MAX_LINES_TO_SHOW * wrapWidth * 4
 86    const preTruncated = trimmedContent.length > maxChars
 87    const contentForWrapping = preTruncated
 88      ? trimmedContent.slice(0, maxChars)
 89      : trimmedContent
 90  
 91    const { aboveTheFold, remainingLines } = wrapText(
 92      contentForWrapping,
 93      wrapWidth,
 94    )
 95  
 96    const estimatedRemaining = preTruncated
 97      ? Math.max(
 98          remainingLines,
 99          Math.ceil(trimmedContent.length / wrapWidth) - MAX_LINES_TO_SHOW,
100        )
101      : remainingLines
102  
103    return [
104      aboveTheFold,
105      estimatedRemaining > 0
106        ? chalk.dim(
107            `… +${estimatedRemaining} lines${suppressExpandHint ? '' : ` ${ctrlOToExpand()}`}`,
108          )
109        : '',
110    ]
111      .filter(Boolean)
112      .join('\n')
113  }
114  
115  /** Fast check: would OutputLine truncate this content? Counts raw newlines
116   *  only (ignores terminal-width wrapping), so it may return false for a single
117   *  very long line that wraps past 3 visual rows — acceptable, since the common
118   *  case is multi-line output. */
119  export function isOutputLineTruncated(content: string): boolean {
120    let pos = 0
121    // Need more than MAX_LINES_TO_SHOW newlines (content fills > 3 lines).
122    // The +1 accounts for wrapText showing an extra line when remainingLines==1.
123    for (let i = 0; i <= MAX_LINES_TO_SHOW; i++) {
124      pos = content.indexOf('\n', pos)
125      if (pos === -1) return false
126      pos++
127    }
128    // A trailing newline is a terminator, not a new line — match
129    // renderTruncatedContent's trimEnd() behavior.
130    return pos < content.length
131  }