All files / src/cli/output box.ts

63.51% Statements 47/74
32.35% Branches 11/34
90.9% Functions 10/11
64.38% Lines 47/73

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236            15x                                                                   12x     12x 12x 12x     12x 12x 12x                 12x 12x 12x 12x 12x 12x 12x   12x                       12x             85x               36x   36x   85x   85x 85x 85x 85x 85x               36x               85x 85x     85x 85x                     85x   85x 85x 85x   85x                                                                               12x                 11x 11x 11x       11x                 12x 12x 12x   12x             12x                             194x      
import chalk from 'chalk';
import type { OutputMode } from './tty.js';
 
/**
 * Unicode box-drawing characters for TTY mode.
 */
const BOX = {
  topLeft: '┌',
  topRight: '┐',
  bottomLeft: '└',
  bottomRight: '┘',
  horizontal: '─',
  vertical: '│',
  leftT: '├',
  rightT: '┤',
} as const;
 
/**
 * Options for creating a box.
 */
export interface BoxOptions {
  /** Title displayed in the header (left side) */
  title: string;
  /** Badge displayed in the header (right side, e.g., duration) */
  badge?: string;
  /** Output mode for TTY vs non-TTY rendering */
  mode: OutputMode;
  /** Minimum width for the box (default: 50) */
  minWidth?: number;
}
 
/**
 * Renders box-style containers for terminal output.
 * Supports TTY mode with Unicode box characters and CI mode with plain text.
 */
export class BoxRenderer {
  private readonly title: string;
  private readonly badge: string | undefined;
  private readonly mode: OutputMode;
  private readonly width: number;
  private readonly lines: string[] = [];
 
  constructor(options: BoxOptions) {
    this.title = options.title;
    this.badge = options.badge;
    this.mode = options.mode;
 
    // Calculate width based on terminal columns, with min/max constraints
    const minWidth = options.minWidth ?? 50;
    const maxWidth = Math.min(options.mode.columns - 2, 100);
    this.width = Math.max(minWidth, maxWidth);
  }
 
  /**
   * Render the top border with title and optional badge.
   * TTY: ┌─ title ─────────────────────── badge ─┐
   * CI:  === title (badge) ===
   */
  header(): this {
    if (this.mode.isTTY) {
      const titlePart = `${BOX.horizontal} ${this.title} `;
      const badgePart = this.badge ? ` ${this.badge} ${BOX.horizontal}` : BOX.horizontal;
      const titleLen = this.stripAnsi(titlePart).length;
      const badgeLen = this.stripAnsi(badgePart).length;
      const fillLen = Math.max(0, this.width - titleLen - badgeLen - 2);
      const fill = BOX.horizontal.repeat(fillLen);
 
      this.lines.push(
        chalk.dim(BOX.topLeft) +
          chalk.dim(BOX.horizontal) + ' ' +
          chalk.bold(this.title) +
          ' ' + chalk.dim(fill) +
          (this.badge ? chalk.dim(` ${this.badge} `) : '') +
          chalk.dim(BOX.horizontal + BOX.topRight)
      );
    } else E{
      const badgePart = this.badge ? ` (${this.badge})` : '';
      this.lines.push(`=== ${this.title}${badgePart} ===`);
    }
    return this;
  }
 
  /**
   * Get the available content width (excluding borders and padding).
   */
  get contentWidth(): number {
    return this.width - 4; // 2 for borders + 2 for padding spaces
  }
 
  /**
   * Add content lines with side borders (TTY) or plain (CI).
   * Long lines are automatically wrapped to fit within the box.
   */
  content(contentLines: string | string[]): this {
    const lines = Array.isArray(contentLines) ? contentLines : [contentLines];
 
    for (const line of lines) {
      // Wrap long lines to fit within the box
      const wrappedLines = this.wrapLine(line);
 
      for (const wrappedLine of wrappedLines) {
        if (this.mode.isTTY) {
          const strippedLen = this.stripAnsi(wrappedLine).length;
          const padding = Math.max(0, this.width - strippedLen - 4);
          this.lines.push(
            chalk.dim(BOX.vertical) + ' ' + wrappedLine + ' '.repeat(padding) + ' ' + chalk.dim(BOX.vertical)
          );
        } else E{
          this.lines.push(wrappedLine);
        }
      }
    }
    return this;
  }
 
  /**
   * Wrap a line to fit within the content width.
   * Preserves leading indentation on wrapped lines.
   */
  private wrapLine(line: string): string[] {
    const maxWidth = this.contentWidth;
    const stripped = this.stripAnsi(line);
 
    // If it fits, return as-is
    Eif (stripped.length <= maxWidth) {
      return [line];
    }
 
    // For lines with ANSI codes, we need to be careful.
    // For simplicity, if the line has ANSI codes and is too long,
    // we'll wrap the stripped version and lose formatting on continuation lines.
    const hasAnsi = line !== stripped;
 
    // Detect leading indentation
    const indentMatch = stripped.match(/^(\s*)/);
    const indent = indentMatch?.[1] ?? '';
    const textToWrap = hasAnsi ? stripped : line;
 
    const result: string[] = [];
    let remaining = textToWrap;
    let isFirstLine = true;
 
    while (remaining.length > 0) {
      const currentIndent = isFirstLine ? '' : indent;
      const availableWidth = maxWidth - currentIndent.length;
 
      if (this.stripAnsi(remaining).length <= availableWidth) {
        result.push(currentIndent + remaining);
        break;
      }
 
      // Find a good break point (prefer word boundaries)
      let breakPoint = availableWidth;
      const searchStart = Math.max(0, availableWidth - 20);
 
      for (let i = availableWidth; i >= searchStart; i--) {
        if (remaining[i] === ' ') {
          breakPoint = i;
          break;
        }
      }
 
      // If no space found, hard break at max width
      if (breakPoint === availableWidth && remaining[availableWidth] !== ' ') {
        breakPoint = availableWidth;
      }
 
      const chunk = remaining.slice(0, breakPoint);
      result.push(currentIndent + chunk);
 
      // Skip the space at the break point if there is one
      remaining = remaining.slice(breakPoint).trimStart();
      isFirstLine = false;
    }
 
    return result;
  }
 
  /**
   * Add an empty content line.
   */
  blank(): this {
    return this.content('');
  }
 
  /**
   * Render a horizontal divider.
   * TTY: ├─────────────────────────────────────────────┤
   * CI:  ---
   */
  divider(): this {
    if (this.mode.isTTY) {
      const fill = BOX.horizontal.repeat(this.width - 2);
      this.lines.push(chalk.dim(BOX.leftT + fill + BOX.rightT));
    } else E{
      this.lines.push('---');
    }
    return this;
  }
 
  /**
   * Render the bottom border.
   * TTY: └─────────────────────────────────────────────┘
   * CI:  (nothing in CI mode - just ends)
   */
  footer(): this {
    Eif (this.mode.isTTY) {
      const fill = BOX.horizontal.repeat(this.width - 2);
      this.lines.push(chalk.dim(BOX.bottomLeft + fill + BOX.bottomRight));
    }
    return this;
  }
 
  /**
   * Get all rendered lines.
   */
  render(): string[] {
    return [...this.lines];
  }
 
  /**
   * Get the rendered output as a single string.
   */
  toString(): string {
    return this.lines.join('\n');
  }
 
  /**
   * Strip ANSI escape codes from a string for length calculation.
   */
  private stripAnsi(str: string): string {
    // oxlint-disable-next-line no-control-regex
    return str.replace(/\x1b\[[0-9;]*m/g, '');
  }
}