From 574207c32b8bd834d5fa31cd1964da5b77ef879a Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Apr 2026 15:26:58 -0500 Subject: [PATCH 1/8] [terminal] Fix TerminalTable rendering: add row separators and gate horizontal lines on fill char MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render horizontal separators between data rows (same chars as the header/body separator), matching the visual convention of bordered tables - Fix renderSeparator to return undefined when fillChar is empty, preventing malformed lines like ├┼┤ when only corner/junction chars are set - Add test cases for >2 columns, single row, header-only, and empty fillChar Co-Authored-By: Claude Sonnet 4.6 --- libraries/terminal/src/TerminalTable.ts | 22 +++++++++---- .../terminal/src/test/TerminalTable.test.ts | 28 ++++++++++++++++ .../__snapshots__/TerminalTable.test.ts.snap | 33 +++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/libraries/terminal/src/TerminalTable.ts b/libraries/terminal/src/TerminalTable.ts index fd1100f647..448dab3ef5 100644 --- a/libraries/terminal/src/TerminalTable.ts +++ b/libraries/terminal/src/TerminalTable.ts @@ -122,9 +122,6 @@ const DEFAULT_CHARS: ITerminalTableChars = { /** * Renders text data as a fixed-column table suitable for terminal output. * - * Designed as a drop-in replacement for the `cli-table` and `cli-table3` npm packages, - * with correct handling of ANSI escape sequences when calculating column widths. - * * @example * ```typescript * const table = new TerminalTable({ head: ['Name', 'Version'] }); @@ -221,7 +218,7 @@ export class TerminalTable { rightChar: string ): string | undefined => { const line: string = leftChar + columnWidths.map((w) => fillChar.repeat(w)).join(midChar) + rightChar; - return line.length > 0 ? line : undefined; + return fillChar.length > 0 ? line : undefined; }; // Renders a single data row. @@ -264,9 +261,20 @@ export class TerminalTable { } } - // Data rows (no separator between them) - for (const row of this._rows) { - lines.push(renderRow(row)); + // Data rows with separators between them + for (let i: number = 0; i < this._rows.length; i++) { + lines.push(renderRow(this._rows[i])); + if (i < this._rows.length - 1) { + const rowSep: string | undefined = renderSeparator( + leftCenterSeparator, + horizontalCenterSeparator, + centerCenterSeparator, + rightCenterSeparator + ); + if (rowSep !== undefined) { + lines.push(rowSep); + } + } } // Bottom border diff --git a/libraries/terminal/src/test/TerminalTable.test.ts b/libraries/terminal/src/test/TerminalTable.test.ts index 287e276eef..808ac4845d 100644 --- a/libraries/terminal/src/test/TerminalTable.test.ts +++ b/libraries/terminal/src/test/TerminalTable.test.ts @@ -87,4 +87,32 @@ describe(TerminalTable.name, () => { const table: TerminalTable = new TerminalTable(); expect(table.toString()).toBe(''); }); + + it('renders a table with more than two columns', () => { + const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version', 'License'] }); + table.push(['@rushstack/terminal', '1.0.0', 'MIT']); + table.push(['@rushstack/heft', '2.0.0', 'MIT']); + expect(table.toString()).toMatchSnapshot(); + }); + + it('renders a single data row with no spurious trailing separator', () => { + const table: TerminalTable = new TerminalTable(); + table.push(['only', 'row']); + expect(table.toString()).toMatchSnapshot(); + }); + + it('renders a header with no data rows', () => { + const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version'] }); + expect(table.toString()).toMatchSnapshot(); + }); + + it('setting horizontalCenter to empty suppresses row and header separators', () => { + const table: TerminalTable = new TerminalTable({ + head: ['A', 'B'], + borderCharacters: { horizontalCenter: '' } + }); + table.push(['x', 'y']); + table.push(['z', 'w']); + expect(table.toString()).toMatchSnapshot(); + }); }); diff --git a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap index 6046e64bac..a24903a21a 100644 --- a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap @@ -15,18 +15,43 @@ exports[`TerminalTable borderless: true suppresses all borders 1`] = ` exports[`TerminalTable chars overrides are applied on top of borderless 1`] = `" hello | world "`; +exports[`TerminalTable renders a header with no data rows 1`] = ` +"┌──────┬─────────┐ +│ Name │ Version │ +├──────┼─────────┤ +└──────┴─────────┘" +`; + +exports[`TerminalTable renders a single data row with no spurious trailing separator 1`] = ` +"┌──────┬─────┐ +│ only │ row │ +└──────┴─────┘" +`; + exports[`TerminalTable renders a table with a header and rows 1`] = ` "┌─────────────────────┬─────────┐ │ Name │ Version │ ├─────────────────────┼─────────┤ │ @rushstack/terminal │ 1.0.0 │ +├─────────────────────┼─────────┤ │ @rushstack/heft │ 2.0.0 │ └─────────────────────┴─────────┘" `; +exports[`TerminalTable renders a table with more than two columns 1`] = ` +"┌─────────────────────┬─────────┬─────────┐ +│ Name │ Version │ License │ +├─────────────────────┼─────────┼─────────┤ +│ @rushstack/terminal │ 1.0.0 │ MIT │ +├─────────────────────┼─────────┼─────────┤ +│ @rushstack/heft │ 2.0.0 │ MIT │ +└─────────────────────┴─────────┴─────────┘" +`; + exports[`TerminalTable renders a table without a header 1`] = ` "┌─────┬─────┐ │ foo │ bar │ +├─────┼─────┤ │ baz │ qux │ └─────┴─────┘" `; @@ -37,6 +62,14 @@ exports[`TerminalTable respects fixed colWidths 1`] = ` └──────────┴────────┘" `; +exports[`TerminalTable setting horizontalCenter to empty suppresses row and header separators 1`] = ` +"┌───┬───┐ +│ A │ B │ +│ x │ y │ +│ z │ w │ +└───┴───┘" +`; + exports[`TerminalTable strips ANSI codes when calculating column widths 1`] = ` "┌────────────┐ │ Package │ From abcef5bda890d854be6180713bf5a506f494c198 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Apr 2026 16:09:30 -0500 Subject: [PATCH 2/8] [terminal] Add borderColor and headingColor to TerminalTable; normalize test snapshots - Add borderColor option: a styling function applied to all border/gridline characters (horizontal separator lines colored as a whole, vertical chars pre-colorized once before rendering) - Add headingColor option: a styling function applied to header cell text only - Refactor getLines() to pre-compute separator lines and styled vertical chars so rendering functions contain no color logic - Add expectSnapshot() helper in tests using AnsiEscape.formatForTests so snapshot files show readable tokens like [cyan]/[bold] instead of raw escapes - Add tests for borderColor, headingColor, and their combination Co-Authored-By: Claude Sonnet 4.6 --- common/reviews/api/terminal.api.md | 2 + libraries/terminal/src/TerminalTable.ts | 114 +++++++++++------- .../terminal/src/test/TerminalTable.test.ts | 67 +++++++--- .../__snapshots__/TerminalTable.test.ts.snap | 28 ++++- 4 files changed, 149 insertions(+), 62 deletions(-) diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index 0736636071..760ebef988 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -268,9 +268,11 @@ export interface ITerminalTableChars { // @public export interface ITerminalTableOptions { borderCharacters?: Partial; + borderColor?: (text: string) => string; borderless?: boolean; colWidths?: number[]; head?: string[]; + headingColor?: (text: string) => string; } // @public diff --git a/libraries/terminal/src/TerminalTable.ts b/libraries/terminal/src/TerminalTable.ts index 448dab3ef5..e220b24d56 100644 --- a/libraries/terminal/src/TerminalTable.ts +++ b/libraries/terminal/src/TerminalTable.ts @@ -81,6 +81,28 @@ export interface ITerminalTableOptions { * borderless mode. */ borderCharacters?: Partial; + + /** + * A function to apply styling to all border and grid line characters. + * + * @example + * ```typescript + * import { Colorize } from '@rushstack/terminal'; + * new TerminalTable({ borderColor: Colorize.gray }) + * ``` + */ + borderColor?: (text: string) => string; + + /** + * A function to apply styling to the text within header row cells. + * + * @example + * ```typescript + * import { Colorize } from '@rushstack/terminal'; + * new TerminalTable({ headingColor: Colorize.bold }) + * ``` + */ + headingColor?: (text: string) => string; } const BORDERLESS_CHARS: ITerminalTableChars = { @@ -136,16 +158,20 @@ export class TerminalTable { private readonly _head: string[]; private readonly _specifiedColWidths: (number | undefined)[]; private readonly _borderCharacters: ITerminalTableChars; + private readonly _borderColor: ((text: string) => string) | undefined; + private readonly _headingColor: ((text: string) => string) | undefined; private readonly _rows: string[][]; public constructor(options: ITerminalTableOptions = {}) { - const { head, colWidths, borderless, borderCharacters } = options; + const { head, colWidths, borderless, borderCharacters, borderColor, headingColor } = options; this._head = head ?? []; this._specifiedColWidths = colWidths ?? []; this._borderCharacters = { ...(borderless ? BORDERLESS_CHARS : DEFAULT_CHARS), ...borderCharacters }; + this._borderColor = borderColor; + this._headingColor = headingColor; this._rows = []; } @@ -163,6 +189,8 @@ export class TerminalTable { _head: head, _rows: rows, _specifiedColWidths: specifiedColWidths, + _borderColor: borderColor, + _headingColor: headingColor, _borderCharacters: { top: topSeparator, topCenter: topCenterSeparator, @@ -210,80 +238,80 @@ export class TerminalTable { } } - // Renders a horizontal separator line. Returns undefined if the result would be empty. - const renderSeparator = ( + // Builds a styled horizontal separator line; returns undefined if fillChar is empty (suppressed). + const buildSepLine = ( leftChar: string, fillChar: string, midChar: string, rightChar: string ): string | undefined => { + if (fillChar.length === 0) { + return undefined; + } const line: string = leftChar + columnWidths.map((w) => fillChar.repeat(w)).join(midChar) + rightChar; - return fillChar.length > 0 ? line : undefined; + return borderColor ? borderColor(line) : line; }; - // Renders a single data row. - const renderRow = (row: string[]): string => { + // Pre-compute all separator lines (borderColor applied once per line, not per character). + const topLine: string | undefined = buildSepLine( + topLeftSeparator, + topSeparator, + topCenterSeparator, + topRightSeparator + ); + const centerLine: string | undefined = buildSepLine( + leftCenterSeparator, + horizontalCenterSeparator, + centerCenterSeparator, + rightCenterSeparator + ); + const bottomLine: string | undefined = buildSepLine( + bottomLeftSeparator, + bottomSeparator, + bottomCenterSeparator, + bottomRightSeparator + ); + + // Pre-colorize vertical border chars used in data rows. + const styledLeft: string = borderColor && leftSeparator ? borderColor(leftSeparator) : leftSeparator; + const styledMid: string = + borderColor && verticalCenterSeparator ? borderColor(verticalCenterSeparator) : verticalCenterSeparator; + const styledRight: string = borderColor && rightSeparator ? borderColor(rightSeparator) : rightSeparator; + + // Renders a single data row. If contentColor is provided, it is applied to each cell's text. + const renderRow = (row: string[], contentColor?: (text: string) => string): string => { const cells: string[] = []; for (let col: number = 0; col < columnCount; col++) { const content: string = col < row.length ? row[col] : ''; const visualWidth: number = AnsiEscape.removeCodes(content).length; // 1 char of left-padding; right-padding fills the remainder of the column width. const padRight: number = Math.max(columnWidths[col] - 1 - visualWidth, 0); - cells.push(' ' + content + ' '.repeat(padRight)); + const styledContent: string = content && contentColor ? contentColor(content) : content; + cells.push(' ' + styledContent + ' '.repeat(padRight)); } - return leftSeparator + cells.join(verticalCenterSeparator) + rightSeparator; + return styledLeft + cells.join(styledMid) + styledRight; }; const lines: string[] = []; - // Top border - const topLine: string | undefined = renderSeparator( - topLeftSeparator, - topSeparator, - topCenterSeparator, - topRightSeparator - ); if (topLine !== undefined) { lines.push(topLine); } - // Header row + separator if (head.length > 0) { - lines.push(renderRow(head)); - const headerSep: string | undefined = renderSeparator( - leftCenterSeparator, - horizontalCenterSeparator, - centerCenterSeparator, - rightCenterSeparator - ); - if (headerSep !== undefined) { - lines.push(headerSep); + lines.push(renderRow(head, headingColor)); + if (centerLine !== undefined) { + lines.push(centerLine); } } - // Data rows with separators between them for (let i: number = 0; i < this._rows.length; i++) { lines.push(renderRow(this._rows[i])); - if (i < this._rows.length - 1) { - const rowSep: string | undefined = renderSeparator( - leftCenterSeparator, - horizontalCenterSeparator, - centerCenterSeparator, - rightCenterSeparator - ); - if (rowSep !== undefined) { - lines.push(rowSep); - } + if (i < this._rows.length - 1 && centerLine !== undefined) { + lines.push(centerLine); } } - // Bottom border - const bottomLine: string | undefined = renderSeparator( - bottomLeftSeparator, - bottomSeparator, - bottomCenterSeparator, - bottomRightSeparator - ); if (bottomLine !== undefined) { lines.push(bottomLine); } diff --git a/libraries/terminal/src/test/TerminalTable.test.ts b/libraries/terminal/src/test/TerminalTable.test.ts index 808ac4845d..6b0554ba35 100644 --- a/libraries/terminal/src/test/TerminalTable.test.ts +++ b/libraries/terminal/src/test/TerminalTable.test.ts @@ -1,21 +1,27 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { AnsiEscape } from '../AnsiEscape'; +import { Colorize } from '../Colorize'; import { TerminalTable } from '../TerminalTable'; +function expectSnapshot(table: TerminalTable): void { + expect(AnsiEscape.formatForTests(table.toString())).toMatchSnapshot(); +} + describe(TerminalTable.name, () => { it('renders a table with a header and rows', () => { const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version'] }); table.push(['@rushstack/terminal', '1.0.0']); table.push(['@rushstack/heft', '2.0.0']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); it('renders a table without a header', () => { const table: TerminalTable = new TerminalTable(); table.push(['foo', 'bar']); table.push(['baz', 'qux']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); it('auto-sizes columns to the widest content', () => { @@ -26,15 +32,13 @@ describe(TerminalTable.name, () => { const lines: string[] = output.split('\n'); const dataRow: string = lines.find((l) => l.includes('short'))!; expect(dataRow).toContain('a very long value here'); - expect(output).toMatchSnapshot(); + expectSnapshot(table); }); it('respects fixed colWidths', () => { const table: TerminalTable = new TerminalTable({ colWidths: [10, 8] }); table.push(['hi', 'there']); - const row: string = table.toString(); - // Cell 0 padded to 10, cell 1 padded to 8 - expect(row).toMatchSnapshot(); + expectSnapshot(table); }); it('borderless: true suppresses all borders', () => { @@ -44,7 +48,7 @@ describe(TerminalTable.name, () => { }); table.push(['alpha', 'beta', 'g']); table.push(['longer text', 'x', 'y']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); it('produces one line per row when borderless (for inquirer-style usage)', () => { @@ -66,21 +70,19 @@ describe(TerminalTable.name, () => { colWidths: [10, 8] }); table.push(['hello', 'world']); - const row: string = table.toString(); - expect(row).toContain(' | '); - expect(table.toString()).toMatchSnapshot(); + expect(table.toString()).toContain(' | '); + expectSnapshot(table); }); it('strips ANSI codes when calculating column widths', () => { const table: TerminalTable = new TerminalTable({ head: ['Package'] }); // Simulate a colored package name — ANSI codes should not inflate the column width - const colored: string = '\x1b[33mmy-package\x1b[0m'; // yellow "my-package" (10 chars visible) + const colored: string = Colorize.yellow('my-package'); // yellow "my-package" (10 chars visible) table.push([colored]); - const output: string = table.toString(); // Column width should be 10 + 2 = 12 (not inflated by escape codes) - const dataRow: string = output.split('\n').find((l) => l.includes('my-package'))!; + const dataRow: string = table.getLines().find((l) => l.includes('my-package'))!; expect(dataRow).toBeDefined(); - expect(output).toMatchSnapshot(); + expectSnapshot(table); }); it('returns empty string for an empty table', () => { @@ -92,18 +94,47 @@ describe(TerminalTable.name, () => { const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version', 'License'] }); table.push(['@rushstack/terminal', '1.0.0', 'MIT']); table.push(['@rushstack/heft', '2.0.0', 'MIT']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); it('renders a single data row with no spurious trailing separator', () => { const table: TerminalTable = new TerminalTable(); table.push(['only', 'row']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); it('renders a header with no data rows', () => { const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version'] }); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); + }); + + it('borderColor is applied to all border characters', () => { + const table: TerminalTable = new TerminalTable({ + head: ['Name', 'Version'], + borderColor: Colorize.cyan + }); + table.push(['foo', '1.0.0']); + table.push(['bar', '2.0.0']); + expectSnapshot(table); + }); + + it('headingColor is applied to header cell text but not to borders or data rows', () => { + const table: TerminalTable = new TerminalTable({ + head: ['Name', 'Version'], + headingColor: Colorize.bold + }); + table.push(['foo', '1.0.0']); + expectSnapshot(table); + }); + + it('borderColor and headingColor can be combined', () => { + const table: TerminalTable = new TerminalTable({ + head: ['Name', 'Version'], + borderColor: Colorize.gray, + headingColor: Colorize.bold + }); + table.push(['foo', '1.0.0']); + expectSnapshot(table); }); it('setting horizontalCenter to empty suppresses row and header separators', () => { @@ -113,6 +144,6 @@ describe(TerminalTable.name, () => { }); table.push(['x', 'y']); table.push(['z', 'w']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); }); diff --git a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap index a24903a21a..d1593b3773 100644 --- a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap @@ -8,6 +8,24 @@ exports[`TerminalTable auto-sizes columns to the widest content 1`] = ` └───────┴────────────────────────┘" `; +exports[`TerminalTable borderColor and headingColor can be combined 1`] = ` +"[gray]┌──────┬─────────┐[default] +[gray]│[default] [bold]Name[normal] [gray]│[default] [bold]Version[normal] [gray]│[default] +[gray]├──────┼─────────┤[default] +[gray]│[default] foo [gray]│[default] 1.0.0 [gray]│[default] +[gray]└──────┴─────────┘[default]" +`; + +exports[`TerminalTable borderColor is applied to all border characters 1`] = ` +"[cyan]┌──────┬─────────┐[default] +[cyan]│[default] Name [cyan]│[default] Version [cyan]│[default] +[cyan]├──────┼─────────┤[default] +[cyan]│[default] foo [cyan]│[default] 1.0.0 [cyan]│[default] +[cyan]├──────┼─────────┤[default] +[cyan]│[default] bar [cyan]│[default] 2.0.0 [cyan]│[default] +[cyan]└──────┴─────────┘[default]" +`; + exports[`TerminalTable borderless: true suppresses all borders 1`] = ` " alpha beta g longer text x y " @@ -15,6 +33,14 @@ exports[`TerminalTable borderless: true suppresses all borders 1`] = ` exports[`TerminalTable chars overrides are applied on top of borderless 1`] = `" hello | world "`; +exports[`TerminalTable headingColor is applied to header cell text but not to borders or data rows 1`] = ` +"┌──────┬─────────┐ +│ [bold]Name[normal] │ [bold]Version[normal] │ +├──────┼─────────┤ +│ foo │ 1.0.0 │ +└──────┴─────────┘" +`; + exports[`TerminalTable renders a header with no data rows 1`] = ` "┌──────┬─────────┐ │ Name │ Version │ @@ -74,6 +100,6 @@ exports[`TerminalTable strips ANSI codes when calculating column widths 1`] = ` "┌────────────┐ │ Package │ ├────────────┤ -│ my-package │ +│ [yellow]my-package[default] │ └────────────┘" `; From 7f83ce4941500972d118ce0c5b18b96367e34f3b Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Apr 2026 16:42:53 -0500 Subject: [PATCH 3/8] [terminal] Add borderColor and messageColor to PrintUtilities.printMessageInBox - Add IPrintMessageInBoxOptions with borderColor and messageColor styling functions, matching the pattern used by TerminalTable - Add tests for both options and their combination, including the banner (wide-content) fallback layout - Fix validateOutput to measure visual width via AnsiEscape.removeCodes so ANSI escape codes don't inflate the length check Co-Authored-By: Claude Sonnet 4.6 --- common/reviews/api/terminal.api.md | 3 +- libraries/terminal/src/PrintUtilities.ts | 55 +++++- .../terminal/src/test/PrintUtilities.test.ts | 53 ++++-- .../__snapshots__/PrintUtilities.test.ts.snap | 165 +++++++++++------- 4 files changed, 191 insertions(+), 85 deletions(-) diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index 760ebef988..d71429e590 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -348,8 +348,9 @@ export class PrefixProxyTerminalProvider implements ITerminalProvider { // @public export class PrintUtilities { static getConsoleWidth(): number | undefined; + // Warning: (ae-forgotten-export) The symbol "IPrintMessageInBoxOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-incompatible-release-tags) The symbol "printMessageInBox" is marked as @public, but its signature references "ITerminal" which is marked as @beta - static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number): void; + static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number, options?: IPrintMessageInBoxOptions): void; static wrapWords(text: string, maxLineLength?: number, indent?: number): string; static wrapWords(text: string, maxLineLength?: number, linePrefix?: string): string; static wrapWords(text: string, maxLineLength?: number, indentOrLinePrefix?: number | string): string; diff --git a/libraries/terminal/src/PrintUtilities.ts b/libraries/terminal/src/PrintUtilities.ts index 6fe00c1365..a65ad8a6a3 100644 --- a/libraries/terminal/src/PrintUtilities.ts +++ b/libraries/terminal/src/PrintUtilities.ts @@ -5,6 +5,35 @@ import { Text } from '@rushstack/node-core-library'; import type { ITerminal } from './ITerminal'; +/** + * Options for {@link PrintUtilities.printMessageInBox}. + * + * @public + */ +export interface IPrintMessageInBoxOptions { + /** + * A function to apply styling to the box border characters. + * + * @example + * ```typescript + * import { Colorize } from '@rushstack/terminal'; + * PrintUtilities.printMessageInBox('Hello!', terminal, undefined, { borderColor: Colorize.cyan }) + * ``` + */ + borderColor?: (text: string) => string; + + /** + * A function to apply styling to the message text inside the box. + * + * @example + * ```typescript + * import { Colorize } from '@rushstack/terminal'; + * PrintUtilities.printMessageInBox('Hello!', terminal, undefined, { messageColor: Colorize.bold }) + * ``` + */ + messageColor?: (text: string) => string; +} + /** * A sensible fallback column width for consoles. * @@ -187,13 +216,23 @@ export class PrintUtilities { * @param message - The message to display. * @param terminal - The terminal to write the message to. * @param boxWidth - The width of the box, defaults to half of the console width. + * @param options - Optional styling for the border and message text. */ - public static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number): void { + public static printMessageInBox( + message: string, + terminal: ITerminal, + boxWidth?: number, + options?: IPrintMessageInBoxOptions + ): void { if (!boxWidth) { const consoleWidth: number = PrintUtilities.getConsoleWidth() || DEFAULT_CONSOLE_WIDTH; boxWidth = Math.floor(consoleWidth / 2); } + const { borderColor, messageColor } = options ?? {}; + const styleBorder = (s: string): string => borderColor?.(s) ?? s; + const styleMessage = (s: string): string => messageColor?.(s) ?? s; + const maxLineLength: number = boxWidth - 10; const wrappedMessageLines: string[] = PrintUtilities.wrapWordsToLines(message, maxLineLength); let longestLineLength: number = 0; @@ -209,25 +248,29 @@ export class PrintUtilities { // ═════════════ // Message // ═════════════ - const headerAndFooter: string = ` ${'═'.repeat(boxWidth)}`; + const headerAndFooter: string = ` ${styleBorder('═'.repeat(boxWidth))}`; terminal.writeLine(headerAndFooter); for (const line of wrappedMessageLines) { - terminal.writeLine(` ${line}`); + terminal.writeLine(` ${styleMessage(line)}`); } terminal.writeLine(headerAndFooter); } else { + const verticalBorder: string = styleBorder('║'); + // ╔═══════════╗ // ║ Message ║ // ╚═══════════╝ - terminal.writeLine(` ╔${'═'.repeat(boxWidth - 2)}╗`); + terminal.writeLine(` ${styleBorder(`╔${'═'.repeat(boxWidth - 2)}╗`)}`); for (const trimmedLine of trimmedLines) { const padding: number = boxWidth - trimmedLine.length - 2; const leftPadding: number = Math.floor(padding / 2); const rightPadding: number = padding - leftPadding; - terminal.writeLine(` ║${' '.repeat(leftPadding)}${trimmedLine}${' '.repeat(rightPadding)}║`); + terminal.writeLine( + ` ${verticalBorder}${' '.repeat(leftPadding)}${styleMessage(trimmedLine)}${' '.repeat(rightPadding)}${verticalBorder}` + ); } - terminal.writeLine(` ╚${'═'.repeat(boxWidth - 2)}╝`); + terminal.writeLine(` ${styleBorder(`╚${'═'.repeat(boxWidth - 2)}╝`)}`); } } } diff --git a/libraries/terminal/src/test/PrintUtilities.test.ts b/libraries/terminal/src/test/PrintUtilities.test.ts index f90cf0807a..d0411db847 100644 --- a/libraries/terminal/src/test/PrintUtilities.test.ts +++ b/libraries/terminal/src/test/PrintUtilities.test.ts @@ -3,17 +3,11 @@ import { StringBufferTerminalProvider } from '../StringBufferTerminalProvider'; import { Terminal } from '../Terminal'; +import { AnsiEscape } from '../AnsiEscape'; +import { Colorize } from '../Colorize'; import { PrintUtilities } from '../PrintUtilities'; describe(PrintUtilities.name, () => { - let terminalProvider: StringBufferTerminalProvider; - let terminal: Terminal; - - beforeEach(() => { - terminalProvider = new StringBufferTerminalProvider(false); - terminal = new Terminal(terminalProvider); - }); - afterEach(() => { jest.resetAllMocks(); }); @@ -97,13 +91,27 @@ describe(PrintUtilities.name, () => { }); describe(PrintUtilities.printMessageInBox.name, () => { + let terminalProvider: StringBufferTerminalProvider; + let terminal: Terminal; + + beforeEach(() => { + terminalProvider = new StringBufferTerminalProvider(true); + terminal = new Terminal(terminalProvider); + }); + + afterEach(() => { + expect(terminalProvider.getAllOutputAsChunks({ asLines: true })).toMatchSnapshot(); + }); + function validateOutput(expectedWidth: number): void { const outputLines: string[] = terminalProvider .getOutput({ normalizeSpecialCharacters: false }) .split('\n'); - expect(outputLines).toMatchSnapshot(); - - expect(outputLines.every((x) => x.length <= expectedWidth)); + expect( + outputLines.every((x) => { + return AnsiEscape.removeCodes(x).length <= expectedWidth; + }) + ); } const MESSAGE: string = @@ -150,6 +158,29 @@ describe(PrintUtilities.name, () => { validateOutput(50); }); + it('applies borderColor to all box border characters', () => { + PrintUtilities.printMessageInBox('Hello world', terminal, 30, { borderColor: Colorize.cyan }); + validateOutput(30); + }); + + it('applies messageColor to message text inside the box', () => { + PrintUtilities.printMessageInBox('Hello world', terminal, 30, { messageColor: Colorize.bold }); + validateOutput(30); + }); + + it('applies borderColor and messageColor together', () => { + PrintUtilities.printMessageInBox('Hello world', terminal, 30, { + borderColor: Colorize.gray, + messageColor: Colorize.bold + }); + validateOutput(30); + }); + + it('applies borderColor in the banner (wide-content) fallback layout', () => { + PrintUtilities.printMessageInBox('Hello world', terminal, 5, { borderColor: Colorize.cyan }); + validateOutput(5); + }); + it('word-wraps a message with a trailing fragment', () => { const lines: string[] = PrintUtilities.wrapWordsToLines( 'This Thursday, we will complete the Node.js version upgrade. Any pipelines that still have not upgraded will be temporarily disabled.', diff --git a/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap b/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap index ba195e50a0..66ac471b38 100644 --- a/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap @@ -2,96 +2,127 @@ exports[`PrintUtilities printMessageInBox Handles a case where there is a word longer than the boxwidth 1`] = ` Array [ - " ══════════════════════════════════════════════════", - " Annnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", - " error occurred while pushing commits to", - " git remote. Please make sure you have", - " installed and enabled git lfs. The", - " easiest way to do that is run the", - " provided setup script:", - " ", - " common/scripts/setup.sh", - " ", - " ══════════════════════════════════════════════════", - "", + "[ log] ══════════════════════════════════════════════════[n]", + "[ log] Annnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn[n]", + "[ log] error occurred while pushing commits to[n]", + "[ log] git remote. Please make sure you have[n]", + "[ log] installed and enabled git lfs. The[n]", + "[ log] easiest way to do that is run the[n]", + "[ log] provided setup script:[n]", + "[ log] [n]", + "[ log] common/scripts/setup.sh[n]", + "[ log] [n]", + "[ log] ══════════════════════════════════════════════════[n]", +] +`; + +exports[`PrintUtilities printMessageInBox applies borderColor and messageColor together 1`] = ` +Array [ + "[ log] [gray]╔════════════════════════════╗[default][n]", + "[ log] [gray]║[default] [bold]Hello world[normal] [gray]║[default][n]", + "[ log] [gray]╚════════════════════════════╝[default][n]", +] +`; + +exports[`PrintUtilities printMessageInBox applies borderColor in the banner (wide-content) fallback layout 1`] = ` +Array [ + "[ log] [cyan]═════[default][n]", + "[ log] Hello[n]", + "[ log] [n]", + "[ log] world[n]", + "[ log] [cyan]═════[default][n]", +] +`; + +exports[`PrintUtilities printMessageInBox applies borderColor to all box border characters 1`] = ` +Array [ + "[ log] [cyan]╔════════════════════════════╗[default][n]", + "[ log] [cyan]║[default] Hello world [cyan]║[default][n]", + "[ log] [cyan]╚════════════════════════════╝[default][n]", +] +`; + +exports[`PrintUtilities printMessageInBox applies messageColor to message text inside the box 1`] = ` +Array [ + "[ log] ╔════════════════════════════╗[n]", + "[ log] ║ [bold]Hello world[normal] ║[n]", + "[ log] ╚════════════════════════════╝[n]", ] `; exports[`PrintUtilities printMessageInBox prints a long message wrapped in a box using the console width 1`] = ` Array [ - " ╔══════════════════════════════╗", - " ║ Lorem ipsum dolor sit ║", - " ║ amet, consectetuer ║", - " ║ adipiscing elit. ║", - " ║ Maecenas porttitor ║", - " ║ congue massa. Fusce ║", - " ║ posuere, magna sed ║", - " ║ pulvinar ultricies, ║", - " ║ purus lectus malesuada ║", - " ║ libero, sit amet ║", - " ║ commodo magna eros ║", - " ║ quis urna. ║", - " ╚══════════════════════════════╝", - "", + "[ log] ╔══════════════════════════════╗[n]", + "[ log] ║ Lorem ipsum dolor sit ║[n]", + "[ log] ║ amet, consectetuer ║[n]", + "[ log] ║ adipiscing elit. ║[n]", + "[ log] ║ Maecenas porttitor ║[n]", + "[ log] ║ congue massa. Fusce ║[n]", + "[ log] ║ posuere, magna sed ║[n]", + "[ log] ║ pulvinar ultricies, ║[n]", + "[ log] ║ purus lectus malesuada ║[n]", + "[ log] ║ libero, sit amet ║[n]", + "[ log] ║ commodo magna eros ║[n]", + "[ log] ║ quis urna. ║[n]", + "[ log] ╚══════════════════════════════╝[n]", ] `; exports[`PrintUtilities printMessageInBox prints a long message wrapped in a wide box 1`] = ` Array [ - " ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗", - " ║ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna. ║", - " ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝", - "", + "[ log] ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗[n]", + "[ log] ║ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna. ║[n]", + "[ log] ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝[n]", ] `; exports[`PrintUtilities printMessageInBox prints a long message wrapped in narrow box 1`] = ` Array [ - " ╔══════════════════╗", - " ║ Lorem ║", - " ║ ipsum ║", - " ║ dolor sit ║", - " ║ amet, ║", - " ║ consectetuer ║", - " ║ adipiscing elit. ║", - " ║ Maecenas ║", - " ║ porttitor ║", - " ║ congue ║", - " ║ massa. ║", - " ║ Fusce ║", - " ║ posuere, ║", - " ║ magna sed ║", - " ║ pulvinar ║", - " ║ ultricies, ║", - " ║ purus ║", - " ║ lectus ║", - " ║ malesuada ║", - " ║ libero, ║", - " ║ sit amet ║", - " ║ commodo ║", - " ║ magna eros ║", - " ║ quis urna. ║", - " ╚══════════════════╝", - "", + "[ log] ╔══════════════════╗[n]", + "[ log] ║ Lorem ║[n]", + "[ log] ║ ipsum ║[n]", + "[ log] ║ dolor sit ║[n]", + "[ log] ║ amet, ║[n]", + "[ log] ║ consectetuer ║[n]", + "[ log] ║ adipiscing elit. ║[n]", + "[ log] ║ Maecenas ║[n]", + "[ log] ║ porttitor ║[n]", + "[ log] ║ congue ║[n]", + "[ log] ║ massa. ║[n]", + "[ log] ║ Fusce ║[n]", + "[ log] ║ posuere, ║[n]", + "[ log] ║ magna sed ║[n]", + "[ log] ║ pulvinar ║[n]", + "[ log] ║ ultricies, ║[n]", + "[ log] ║ purus ║[n]", + "[ log] ║ lectus ║[n]", + "[ log] ║ malesuada ║[n]", + "[ log] ║ libero, ║[n]", + "[ log] ║ sit amet ║[n]", + "[ log] ║ commodo ║[n]", + "[ log] ║ magna eros ║[n]", + "[ log] ║ quis urna. ║[n]", + "[ log] ╚══════════════════╝[n]", ] `; exports[`PrintUtilities printMessageInBox respects spaces and newlines in a pre-formatted message 1`] = ` Array [ - " ╔════════════════════════════════════════════════╗", - " ║ An error occurred while pushing commits ║", - " ║ to git remote. Please make sure you have ║", - " ║ installed and enabled git lfs. The ║", - " ║ easiest way to do that is run the ║", - " ║ provided setup script: ║", - " ║ ║", - " ║ common/scripts/setup.sh ║", - " ║ ║", - " ╚════════════════════════════════════════════════╝", - "", + "[ log] ╔════════════════════════════════════════════════╗[n]", + "[ log] ║ An error occurred while pushing commits ║[n]", + "[ log] ║ to git remote. Please make sure you have ║[n]", + "[ log] ║ installed and enabled git lfs. The ║[n]", + "[ log] ║ easiest way to do that is run the ║[n]", + "[ log] ║ provided setup script: ║[n]", + "[ log] ║ ║[n]", + "[ log] ║ common/scripts/setup.sh ║[n]", + "[ log] ║ ║[n]", + "[ log] ╚════════════════════════════════════════════════╝[n]", ] `; +exports[`PrintUtilities printMessageInBox word-wraps a message with a trailing fragment 2`] = `Array []`; + exports[`PrintUtilities wrapWordsToLines with prefix="| " applies pre-existing indents on both margins 1`] = ` Array [ "| Lorem ipsum dolor sit amet, consectetuer", From 7015ce4452a2027aefe6e3473fcb4c2f96ec78be Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Apr 2026 16:56:36 -0500 Subject: [PATCH 4/8] [terminal] API cleanup: export IPrintMessageInBoxOptions, fix TSDoc overload references - Export IPrintMessageInBoxOptions from the package entry point - Add JSDoc for the boxWidth option - Fix @example snippets to use the new 3-arg signature - Use {@label WITH_OPTIONS} and (printMessageInBox:1) selector to unambiguously reference the primary overload - Update tests to use the options-based API Co-Authored-By: Claude Sonnet 4.6 --- common/reviews/api/terminal.api.md | 14 ++++++- libraries/terminal/src/PrintUtilities.ts | 42 +++++++++++++++---- libraries/terminal/src/index.ts | 2 +- .../terminal/src/test/PrintUtilities.test.ts | 21 ++++++---- 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index d71429e590..508b0e0da4 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -167,6 +167,13 @@ export interface IPrefixProxyTerminalProviderOptionsBase { terminalProvider: ITerminalProvider; } +// @public +export interface IPrintMessageInBoxOptions { + borderColor?: (text: string) => string; + boxWidth?: number; + messageColor?: (text: string) => string; +} + // @beta export interface IProblemCollector { get problems(): ReadonlySet; @@ -348,9 +355,12 @@ export class PrefixProxyTerminalProvider implements ITerminalProvider { // @public export class PrintUtilities { static getConsoleWidth(): number | undefined; - // Warning: (ae-forgotten-export) The symbol "IPrintMessageInBoxOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-incompatible-release-tags) The symbol "printMessageInBox" is marked as @public, but its signature references "ITerminal" which is marked as @beta - static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number, options?: IPrintMessageInBoxOptions): void; + static printMessageInBox(message: string, terminal: ITerminal, options?: IPrintMessageInBoxOptions): void; + // Warning: (ae-incompatible-release-tags) The symbol "printMessageInBox" is marked as @public, but its signature references "ITerminal" which is marked as @beta + // + // @deprecated (undocumented) + static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number): void; static wrapWords(text: string, maxLineLength?: number, indent?: number): string; static wrapWords(text: string, maxLineLength?: number, linePrefix?: string): string; static wrapWords(text: string, maxLineLength?: number, indentOrLinePrefix?: number | string): string; diff --git a/libraries/terminal/src/PrintUtilities.ts b/libraries/terminal/src/PrintUtilities.ts index a65ad8a6a3..4b01f18e72 100644 --- a/libraries/terminal/src/PrintUtilities.ts +++ b/libraries/terminal/src/PrintUtilities.ts @@ -6,18 +6,23 @@ import { Text } from '@rushstack/node-core-library'; import type { ITerminal } from './ITerminal'; /** - * Options for {@link PrintUtilities.printMessageInBox}. + * Options for {@link PrintUtilities.(printMessageInBox:1)}. * * @public */ export interface IPrintMessageInBoxOptions { + /** + * The width of the box in characters. Defaults to half of the console width. + */ + boxWidth?: number; + /** * A function to apply styling to the box border characters. * * @example * ```typescript * import { Colorize } from '@rushstack/terminal'; - * PrintUtilities.printMessageInBox('Hello!', terminal, undefined, { borderColor: Colorize.cyan }) + * PrintUtilities.printMessageInBox('Hello!', terminal, { borderColor: Colorize.cyan }) * ``` */ borderColor?: (text: string) => string; @@ -28,7 +33,7 @@ export interface IPrintMessageInBoxOptions { * @example * ```typescript * import { Colorize } from '@rushstack/terminal'; - * PrintUtilities.printMessageInBox('Hello!', terminal, undefined, { messageColor: Colorize.bold }) + * PrintUtilities.printMessageInBox('Hello!', terminal, { messageColor: Colorize.bold }) * ``` */ messageColor?: (text: string) => string; @@ -215,21 +220,42 @@ export class PrintUtilities { * * @param message - The message to display. * @param terminal - The terminal to write the message to. - * @param boxWidth - The width of the box, defaults to half of the console width. - * @param options - Optional styling for the border and message text. + * @param options - Controls the box width and optional styling for the border and message text. + * {@label WITH_OPTIONS} */ public static printMessageInBox( message: string, terminal: ITerminal, - boxWidth?: number, options?: IPrintMessageInBoxOptions + ): void; + /** + * @deprecated Use the {@link PrintUtilities.(printMessageInBox:1)} overload instead. + * Pass `boxWidth` via the {@link IPrintMessageInBoxOptions.boxWidth} property. + */ + public static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number): void; + public static printMessageInBox( + message: string, + terminal: ITerminal, + optionsOrBoxWidth?: IPrintMessageInBoxOptions | number ): void { - if (!boxWidth) { + let options: IPrintMessageInBoxOptions; + if (typeof optionsOrBoxWidth === 'number') { + options = { + boxWidth: optionsOrBoxWidth + }; + } else { + options = optionsOrBoxWidth ?? {}; + } + + const { borderColor, messageColor, boxWidth: optionsBoxWidth } = options ?? {}; + let boxWidth: number; + if (!optionsBoxWidth) { const consoleWidth: number = PrintUtilities.getConsoleWidth() || DEFAULT_CONSOLE_WIDTH; boxWidth = Math.floor(consoleWidth / 2); + } else { + boxWidth = optionsBoxWidth; } - const { borderColor, messageColor } = options ?? {}; const styleBorder = (s: string): string => borderColor?.(s) ?? s; const styleMessage = (s: string): string => messageColor?.(s) ?? s; diff --git a/libraries/terminal/src/index.ts b/libraries/terminal/src/index.ts index b1d26c135c..89d732acc5 100644 --- a/libraries/terminal/src/index.ts +++ b/libraries/terminal/src/index.ts @@ -21,7 +21,7 @@ export { type INormalizeNewlinesTextRewriterOptions, NormalizeNewlinesTextRewriter } from './NormalizeNewlinesTextRewriter'; -export { DEFAULT_CONSOLE_WIDTH, PrintUtilities } from './PrintUtilities'; +export { DEFAULT_CONSOLE_WIDTH, type IPrintMessageInBoxOptions, PrintUtilities } from './PrintUtilities'; export { RemoveColorsTextRewriter } from './RemoveColorsTextRewriter'; export { type ISplitterTransformOptions, SplitterTransform } from './SplitterTransform'; export { type IStdioLineTransformOptions, StderrLineTransform } from './StdioLineTransform'; diff --git a/libraries/terminal/src/test/PrintUtilities.test.ts b/libraries/terminal/src/test/PrintUtilities.test.ts index d0411db847..888c0f8efc 100644 --- a/libraries/terminal/src/test/PrintUtilities.test.ts +++ b/libraries/terminal/src/test/PrintUtilities.test.ts @@ -118,12 +118,12 @@ describe(PrintUtilities.name, () => { 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna.'; it('prints a long message wrapped in narrow box', () => { - PrintUtilities.printMessageInBox(MESSAGE, terminal, 20); + PrintUtilities.printMessageInBox(MESSAGE, terminal, { boxWidth: 20 }); validateOutput(20); }); it('prints a long message wrapped in a wide box', () => { - PrintUtilities.printMessageInBox(MESSAGE, terminal, 300); + PrintUtilities.printMessageInBox(MESSAGE, terminal, { boxWidth: 300 }); validateOutput(300); }); @@ -142,7 +142,7 @@ describe(PrintUtilities.name, () => { '' ].join('\n'); - PrintUtilities.printMessageInBox(userMessage, terminal, 50); + PrintUtilities.printMessageInBox(userMessage, terminal, { boxWidth: 50 }); validateOutput(50); }); @@ -154,30 +154,35 @@ describe(PrintUtilities.name, () => { '' ].join('\n'); - PrintUtilities.printMessageInBox(userMessage, terminal, 50); + PrintUtilities.printMessageInBox(userMessage, terminal, { boxWidth: 50 }); validateOutput(50); }); it('applies borderColor to all box border characters', () => { - PrintUtilities.printMessageInBox('Hello world', terminal, 30, { borderColor: Colorize.cyan }); + PrintUtilities.printMessageInBox('Hello world', terminal, { boxWidth: 30, borderColor: Colorize.cyan }); validateOutput(30); }); it('applies messageColor to message text inside the box', () => { - PrintUtilities.printMessageInBox('Hello world', terminal, 30, { messageColor: Colorize.bold }); + PrintUtilities.printMessageInBox('Hello world', terminal, { + boxWidth: 30, + messageColor: Colorize.bold + }); validateOutput(30); }); it('applies borderColor and messageColor together', () => { - PrintUtilities.printMessageInBox('Hello world', terminal, 30, { + PrintUtilities.printMessageInBox('Hello world', terminal, { + boxWidth: 30, borderColor: Colorize.gray, messageColor: Colorize.bold }); + validateOutput(30); }); it('applies borderColor in the banner (wide-content) fallback layout', () => { - PrintUtilities.printMessageInBox('Hello world', terminal, 5, { borderColor: Colorize.cyan }); + PrintUtilities.printMessageInBox('Hello world', terminal, { boxWidth: 5, borderColor: Colorize.cyan }); validateOutput(5); }); From cedaf86a7928a00e8e552adad3217c5059656c27 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Apr 2026 16:59:58 -0500 Subject: [PATCH 5/8] [terminal] Add TerminalTable.printToTerminal() Co-Authored-By: Claude Sonnet 4.6 --- common/reviews/api/terminal.api.md | 2 ++ libraries/terminal/src/TerminalTable.ts | 10 ++++++++++ libraries/terminal/src/test/TerminalTable.test.ts | 11 +++++++++++ .../src/test/__snapshots__/TerminalTable.test.ts.snap | 10 ++++++++++ 4 files changed, 33 insertions(+) diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index 508b0e0da4..c478b48bc2 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -504,6 +504,8 @@ export class TerminalTable { constructor(options?: ITerminalTableOptions); // (undocumented) getLines(): string[]; + // Warning: (ae-incompatible-release-tags) The symbol "printToTerminal" is marked as @public, but its signature references "ITerminal" which is marked as @beta + printToTerminal(terminal: ITerminal): void; push(...rows: string[][]): void; toString(): string; } diff --git a/libraries/terminal/src/TerminalTable.ts b/libraries/terminal/src/TerminalTable.ts index e220b24d56..6f1d94645a 100644 --- a/libraries/terminal/src/TerminalTable.ts +++ b/libraries/terminal/src/TerminalTable.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { AnsiEscape } from './AnsiEscape'; +import type { ITerminal } from './ITerminal'; /** * The set of characters used to draw table borders. @@ -326,4 +327,13 @@ export class TerminalTable { const lines: string[] = this.getLines(); return lines.join('\n'); } + + /** + * Writes the rendered table to the provided terminal, one line at a time. + */ + public printToTerminal(terminal: ITerminal): void { + for (const line of this.getLines()) { + terminal.writeLine(line); + } + } } diff --git a/libraries/terminal/src/test/TerminalTable.test.ts b/libraries/terminal/src/test/TerminalTable.test.ts index 6b0554ba35..7504249037 100644 --- a/libraries/terminal/src/test/TerminalTable.test.ts +++ b/libraries/terminal/src/test/TerminalTable.test.ts @@ -3,6 +3,8 @@ import { AnsiEscape } from '../AnsiEscape'; import { Colorize } from '../Colorize'; +import { StringBufferTerminalProvider } from '../StringBufferTerminalProvider'; +import { Terminal } from '../Terminal'; import { TerminalTable } from '../TerminalTable'; function expectSnapshot(table: TerminalTable): void { @@ -146,4 +148,13 @@ describe(TerminalTable.name, () => { table.push(['z', 'w']); expectSnapshot(table); }); + + it('printToTerminal writes each line to the terminal', () => { + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + const terminal: Terminal = new Terminal(terminalProvider); + const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version'] }); + table.push(['@rushstack/terminal', '1.0.0']); + table.printToTerminal(terminal); + expect(terminalProvider.getAllOutputAsChunks({ asLines: true })).toMatchSnapshot(); + }); }); diff --git a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap index d1593b3773..b4d2790078 100644 --- a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap @@ -41,6 +41,16 @@ exports[`TerminalTable headingColor is applied to header cell text but not to bo └──────┴─────────┘" `; +exports[`TerminalTable printToTerminal writes each line to the terminal 1`] = ` +Array [ + "[ log] ┌─────────────────────┬─────────┐[n]", + "[ log] │ Name │ Version │[n]", + "[ log] ├─────────────────────┼─────────┤[n]", + "[ log] │ @rushstack/terminal │ 1.0.0 │[n]", + "[ log] └─────────────────────┴─────────┘[n]", +] +`; + exports[`TerminalTable renders a header with no data rows 1`] = ` "┌──────┬─────────┐ │ Name │ Version │ From 4f4f8e655c1389ad9c08a02ef116f15220a3bde1 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Apr 2026 17:04:30 -0500 Subject: [PATCH 6/8] fixup! [terminal] Add TerminalTable.printToTerminal() --- libraries/rush-lib/src/cli/actions/ListAction.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/rush-lib/src/cli/actions/ListAction.ts b/libraries/rush-lib/src/cli/actions/ListAction.ts index 3793fb19b1..7658301553 100644 --- a/libraries/rush-lib/src/cli/actions/ListAction.ts +++ b/libraries/rush-lib/src/cli/actions/ListAction.ts @@ -277,7 +277,6 @@ export class ListAction extends BaseRushAction { table.push(packageRow); } - // eslint-disable-next-line no-console - console.log(table.toString()); + table.printToTerminal(this.terminal); } } From 4a9371efa7f8934ea59a5749ccdbcc4b3b7803cc Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Apr 2026 17:23:03 -0500 Subject: [PATCH 7/8] Rush change. --- .../terminal-table-improvements_2026-04-18-22-04.json | 11 +++++++++++ .../terminal-table-improvements_2026-04-18-22-04.json | 11 +++++++++++ .../terminal-table-improvements_2026-04-18-22-05.json | 11 +++++++++++ .../terminal-table-improvements_2026-04-18-22-06.json | 11 +++++++++++ .../terminal-table-improvements_2026-04-18-22-08.json | 11 +++++++++++ .../terminal-table-improvements_2026-04-18-22-09.json | 11 +++++++++++ 6 files changed, 66 insertions(+) create mode 100644 common/changes/@microsoft/rush/terminal-table-improvements_2026-04-18-22-04.json create mode 100644 common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-04.json create mode 100644 common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-05.json create mode 100644 common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-06.json create mode 100644 common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-08.json create mode 100644 common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-09.json diff --git a/common/changes/@microsoft/rush/terminal-table-improvements_2026-04-18-22-04.json b/common/changes/@microsoft/rush/terminal-table-improvements_2026-04-18-22-04.json new file mode 100644 index 0000000000..c3db9d4960 --- /dev/null +++ b/common/changes/@microsoft/rush/terminal-table-improvements_2026-04-18-22-04.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Fix an issue where `rush list --detailed` did not print horizontal table separators.", + "type": "none", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-04.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-04.json new file mode 100644 index 0000000000..61a50482dd --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-04.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Fix `TerminalTable` rendering spurious border lines when separator characters are set to empty strings", + "type": "patch", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-05.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-05.json new file mode 100644 index 0000000000..61bd63d176 --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-05.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add borderColor and headingColor styling options to `TerminalTable`", + "type": "minor", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-06.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-06.json new file mode 100644 index 0000000000..d402981e54 --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add borderColor and messageColor styling options to `PrintUtilities.printMessageInBox`", + "type": "minor", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-08.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-08.json new file mode 100644 index 0000000000..c33126485f --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-08.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add row separators between data rows in `TerminalTable`.", + "type": "patch", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-09.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-09.json new file mode 100644 index 0000000000..7d7397aee8 --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add `TerminalTable.printToTerminal()` function.", + "type": "minor", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} From 89ecc2fa6dc157f776516d5a18005a5a87dd7dbe Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 18 Apr 2026 17:23:05 -0500 Subject: [PATCH 8/8] Make the next release of Rush a patch bump. --- common/config/rush/version-policies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index f130c5939c..2ff301abba 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -103,7 +103,7 @@ "policyName": "rush", "definitionName": "lockStepVersion", "version": "5.175.0", - "nextBump": "minor", + "nextBump": "patch", "mainProject": "@microsoft/rush" } ]