From 60b076094f32887ac3088655bc63c8e6401a001c Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 17:28:59 -0700 Subject: [PATCH 1/9] [terminal] Add TerminalTable; [rush-lib] replace cli-table dependency Add TerminalTable to @rushstack/terminal as a drop-in replacement for the cli-table and cli-table3 npm packages. Handles ANSI escape sequences when calculating column widths, and matches the chars/head/colWidths constructor API used by both packages. Replace cli-table usage in rush-lib (ListAction, InteractiveUpgradeUI) with TerminalTable, and remove the cli-table dependency. Co-Authored-By: Claude Sonnet 4.6 --- .../rush/nonbrowser-approved-packages.json | 4 - libraries/rush-lib/package.json | 2 - .../rush-lib/src/cli/actions/ListAction.ts | 5 +- .../src/utilities/InteractiveUpgradeUI.ts | 5 +- libraries/terminal/src/TerminalTable.ts | 203 ++++++++++++++++++ libraries/terminal/src/index.ts | 1 + .../terminal/src/test/TerminalTable.test.ts | 110 ++++++++++ 7 files changed, 318 insertions(+), 12 deletions(-) create mode 100644 libraries/terminal/src/TerminalTable.ts create mode 100644 libraries/terminal/src/test/TerminalTable.test.ts diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index a7cccee93a0..87a7b4f48d6 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -614,10 +614,6 @@ "name": "chokidar", "allowedCategories": [ "libraries" ] }, - { - "name": "cli-table", - "allowedCategories": [ "libraries" ] - }, { "name": "compression", "allowedCategories": [ "libraries" ] diff --git a/libraries/rush-lib/package.json b/libraries/rush-lib/package.json index da8bcf9e7ad..dda2f6d7f5d 100644 --- a/libraries/rush-lib/package.json +++ b/libraries/rush-lib/package.json @@ -58,7 +58,6 @@ "@rushstack/ts-command-line": "workspace:*", "@yarnpkg/lockfile": "~1.0.2", "builtin-modules": "~3.1.0", - "cli-table": "~0.3.1", "dependency-path": "~9.2.8", "dotenv": "~16.4.7", "fast-glob": "~3.3.1", @@ -91,7 +90,6 @@ "@rushstack/operation-graph": "workspace:*", "@rushstack/webpack-deep-imports-plugin": "workspace:*", "@rushstack/webpack-preserve-dynamic-require-plugin": "workspace:*", - "@types/cli-table": "0.3.0", "@types/js-yaml": "4.0.9", "@types/npm-package-arg": "6.1.0", "@types/object-hash": "~3.0.6", diff --git a/libraries/rush-lib/src/cli/actions/ListAction.ts b/libraries/rush-lib/src/cli/actions/ListAction.ts index ec47625028f..3793fb19b13 100644 --- a/libraries/rush-lib/src/cli/actions/ListAction.ts +++ b/libraries/rush-lib/src/cli/actions/ListAction.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. import { Sort } from '@rushstack/node-core-library'; -import { ConsoleTerminalProvider, Terminal } from '@rushstack/terminal'; +import { ConsoleTerminalProvider, Terminal, TerminalTable } from '@rushstack/terminal'; import type { CommandLineFlagParameter } from '@rushstack/ts-command-line'; import { BaseRushAction } from './BaseRushAction'; @@ -220,8 +220,7 @@ export class ListAction extends BaseRushAction { tableHeader.push('Tags'); } - const { default: CliTable } = await import('cli-table'); - const table: import('cli-table') = new CliTable({ + const table: TerminalTable = new TerminalTable({ head: tableHeader }); diff --git a/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts b/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts index 398443ac962..ce700893ce1 100644 --- a/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts +++ b/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts @@ -5,10 +5,9 @@ // https://github.com/dylang/npm-check/blob/master/lib/out/interactive-update.js // Extended to use one type of text table -import CliTable from 'cli-table'; import type { Separator } from '@inquirer/checkbox'; -import { AnsiEscape, Colorize } from '@rushstack/terminal'; +import { AnsiEscape, Colorize, TerminalTable } from '@rushstack/terminal'; import type { INpmCheckPackageSummary } from '@rushstack/npm-check-fork'; export interface IUIGroup { @@ -147,7 +146,7 @@ export const upgradeInteractive = async (pkgs: INpmCheckPackageSummary[]): Promi .map(getChoice) .filter(Boolean); - const cliTable: CliTable = new CliTable({ + const cliTable: TerminalTable = new TerminalTable({ chars: { top: '', 'top-mid': '', diff --git a/libraries/terminal/src/TerminalTable.ts b/libraries/terminal/src/TerminalTable.ts new file mode 100644 index 00000000000..a8cea446ca5 --- /dev/null +++ b/libraries/terminal/src/TerminalTable.ts @@ -0,0 +1,203 @@ +// 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'; + +/** + * The set of characters used to draw table borders. + * + * @public + */ +export interface ITerminalTableChars { + top: string; + 'top-mid': string; + 'top-left': string; + 'top-right': string; + bottom: string; + 'bottom-mid': string; + 'bottom-left': string; + 'bottom-right': string; + left: string; + 'left-mid': string; + mid: string; + 'mid-mid': string; + right: string; + 'right-mid': string; + middle: string; +} + +/** + * Options for {@link TerminalTable}. + * + * @public + */ +export interface ITerminalTableOptions { + /** + * Column header labels. + */ + head?: string[]; + + /** + * Fixed column widths in characters, including one character of padding on each side. + * Columns not listed default to auto-sizing based on content. + */ + colWidths?: number[]; + + /** + * Overrides for individual border characters. + * Pass an empty string for any character to suppress that part of the border. + */ + chars?: Partial; +} + +const DEFAULT_CHARS: ITerminalTableChars = { + top: '─', + 'top-mid': '┬', + 'top-left': '┌', + 'top-right': '┐', + bottom: '─', + 'bottom-mid': '┴', + 'bottom-left': '└', + 'bottom-right': '┘', + left: '│', + 'left-mid': '├', + mid: '─', + 'mid-mid': '┼', + right: '│', + 'right-mid': '┤', + middle: '│' +}; + +/** + * 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'] }); + * table.push(['@rushstack/terminal', '1.0.0']); + * table.push(['@rushstack/heft', '2.0.0']); + * console.log(table.toString()); + * ``` + * + * @public + */ +export class TerminalTable { + private readonly _head: string[]; + private readonly _specifiedColWidths: (number | undefined)[]; + private readonly _chars: ITerminalTableChars; + private readonly _rows: string[][]; + + public constructor(options?: ITerminalTableOptions) { + this._head = options?.head ?? []; + this._specifiedColWidths = options?.colWidths ?? []; + this._chars = { ...DEFAULT_CHARS, ...options?.chars }; + this._rows = []; + } + + /** + * Appends one or more rows to the table. + */ + public push(...rows: string[][]): void { + for (const row of rows) { + this._rows.push(row); + } + } + + /** + * Renders the table to a string. + */ + public toString(): string { + const allRows: string[][] = this._head.length > 0 ? [this._head, ...this._rows] : this._rows; + const colCount: number = Math.max(0, ...allRows.map((r) => r.length)); + if (colCount === 0) { + return ''; + } + + // Resolve final column widths: use specified width if provided, otherwise auto-size from content. + const colWidths: number[] = []; + for (let col: number = 0; col < colCount; col++) { + const specified: number | undefined = this._specifiedColWidths[col]; + if (specified !== undefined) { + colWidths.push(specified); + } else { + let maxContent: number = 0; + for (const row of allRows) { + if (col < row.length) { + const w: number = AnsiEscape.removeCodes(row[col]).length; + if (w > maxContent) maxContent = w; + } + } + // +2 for one character of padding on each side + colWidths.push(maxContent + 2); + } + } + + // Renders a horizontal separator line. Returns undefined if the result would be empty. + const renderSeparator = ( + leftChar: string, + fillChar: string, + midChar: string, + rightChar: string + ): string | undefined => { + const line: string = leftChar + colWidths.map((w) => fillChar.repeat(w)).join(midChar) + rightChar; + return line.length > 0 ? line : undefined; + }; + + // Renders a single data row. + const renderRow = (row: string[]): string => { + const cells: string[] = []; + for (let col: number = 0; col < colCount; 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(colWidths[col] - 1 - visualWidth, 0); + cells.push(' ' + content + ' '.repeat(padRight)); + } + return this._chars.left + cells.join(this._chars.middle) + this._chars.right; + }; + + const lines: string[] = []; + + // Top border + const topLine: string | undefined = renderSeparator( + this._chars['top-left'], + this._chars.top, + this._chars['top-mid'], + this._chars['top-right'] + ); + if (topLine !== undefined) lines.push(topLine); + + // Header row + separator + if (this._head.length > 0) { + lines.push(renderRow(this._head)); + const headerSep: string | undefined = renderSeparator( + this._chars['left-mid'], + this._chars.mid, + this._chars['mid-mid'], + this._chars['right-mid'] + ); + if (headerSep !== undefined) lines.push(headerSep); + } + + // Data rows (no separator between them) + for (const row of this._rows) { + lines.push(renderRow(row)); + } + + // Bottom border + const bottomLine: string | undefined = renderSeparator( + this._chars['bottom-left'], + this._chars.bottom, + this._chars['bottom-mid'], + this._chars['bottom-right'] + ); + if (bottomLine !== undefined) { + lines.push(bottomLine); + } + + return lines.join('\n'); + } +} diff --git a/libraries/terminal/src/index.ts b/libraries/terminal/src/index.ts index cbea552b49e..b1d26c135c0 100644 --- a/libraries/terminal/src/index.ts +++ b/libraries/terminal/src/index.ts @@ -56,3 +56,4 @@ export { NoOpTerminalProvider } from './NoOpTerminalProvider'; export { TerminalStreamWritable, type ITerminalStreamWritableOptions } from './TerminalStreamWritable'; export { ProblemCollector, type IProblemCollectorOptions } from './ProblemCollector'; export type { IProblemCollector } from './IProblemCollector'; +export { TerminalTable, type ITerminalTableOptions, type ITerminalTableChars } from './TerminalTable'; diff --git a/libraries/terminal/src/test/TerminalTable.test.ts b/libraries/terminal/src/test/TerminalTable.test.ts new file mode 100644 index 00000000000..a92a6b930be --- /dev/null +++ b/libraries/terminal/src/test/TerminalTable.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TerminalTable } from '../TerminalTable'; + +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(); + }); + + it('renders a table without a header', () => { + const table: TerminalTable = new TerminalTable(); + table.push(['foo', 'bar']); + table.push(['baz', 'qux']); + expect(table.toString()).toMatchSnapshot(); + }); + + it('auto-sizes columns to the widest content', () => { + const table: TerminalTable = new TerminalTable({ head: ['A', 'B'] }); + table.push(['short', 'a very long value here']); + const output: string = table.toString(); + // "a very long value here" is 22 chars; column width = 22 + 2 = 24 + 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(); + }); + + 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(); + }); + + it('supports empty border chars (invisible borders)', () => { + const table: TerminalTable = new TerminalTable({ + chars: { + top: '', + 'top-mid': '', + 'top-left': '', + 'top-right': '', + bottom: '', + 'bottom-mid': '', + 'bottom-left': '', + 'bottom-right': '', + left: '', + 'left-mid': '', + mid: '', + 'mid-mid': '', + right: '', + 'right-mid': '', + middle: ' ' + }, + colWidths: [10, 8, 6] + }); + table.push(['alpha', 'beta', 'g']); + table.push(['longer text', 'x', 'y']); + expect(table.toString()).toMatchSnapshot(); + }); + + it('produces one line per row when borders are empty (for inquirer-style usage)', () => { + const table: TerminalTable = new TerminalTable({ + chars: { + top: '', + 'top-mid': '', + 'top-left': '', + 'top-right': '', + bottom: '', + 'bottom-mid': '', + 'bottom-left': '', + 'bottom-right': '', + left: '', + 'left-mid': '', + mid: '', + 'mid-mid': '', + right: '', + 'right-mid': '', + middle: ' ' + }, + colWidths: [20, 10] + }); + table.push(['row one', 'v1']); + table.push(['row two', 'v2']); + table.push(['row three', 'v3']); + const lines: string[] = table.toString().split('\n'); + expect(lines.length).toBe(3); + }); + + 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) + 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'))!; + expect(dataRow).toBeDefined(); + expect(output).toMatchSnapshot(); + }); + + it('returns empty string for an empty table', () => { + const table: TerminalTable = new TerminalTable(); + expect(table.toString()).toBe(''); + }); +}); From e797b058ab4ab836410c10455fe99e780d50f456 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 17:29:37 -0700 Subject: [PATCH 2/9] Add changefiles for terminal-table PR Co-Authored-By: Claude Sonnet 4.6 --- .../@microsoft/rush/terminal-table_2026-04-17.json | 10 ++++++++++ .../@rushstack/terminal/terminal-table_2026-04-17.json | 10 ++++++++++ 2 files changed, 20 insertions(+) create mode 100644 common/changes/@microsoft/rush/terminal-table_2026-04-17.json create mode 100644 common/changes/@rushstack/terminal/terminal-table_2026-04-17.json diff --git a/common/changes/@microsoft/rush/terminal-table_2026-04-17.json b/common/changes/@microsoft/rush/terminal-table_2026-04-17.json new file mode 100644 index 00000000000..5ba7d065f19 --- /dev/null +++ b/common/changes/@microsoft/rush/terminal-table_2026-04-17.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Replace cli-table dependency with TerminalTable from @rushstack/terminal.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json b/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json new file mode 100644 index 00000000000..11e2b3580eb --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/terminal", + "comment": "Add TerminalTable class: a drop-in replacement for the cli-table and cli-table3 npm packages that correctly handles ANSI escape sequences when calculating column widths.", + "type": "minor" + } + ], + "packageName": "@rushstack/terminal" +} From 3e97846ecbfb8f478041c3a43f331c99514880df Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 17:37:24 -0700 Subject: [PATCH 3/9] fixup! Add changefiles for terminal-table PR --- common/changes/@microsoft/rush/terminal-table_2026-04-17.json | 2 +- .../changes/@rushstack/terminal/terminal-table_2026-04-17.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/changes/@microsoft/rush/terminal-table_2026-04-17.json b/common/changes/@microsoft/rush/terminal-table_2026-04-17.json index 5ba7d065f19..c85f7e434b9 100644 --- a/common/changes/@microsoft/rush/terminal-table_2026-04-17.json +++ b/common/changes/@microsoft/rush/terminal-table_2026-04-17.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "Replace cli-table dependency with TerminalTable from @rushstack/terminal.", + "comment": "Replace `cli-table` dependency with `TerminalTable` from `@rushstack/terminal`.", "type": "patch" } ], diff --git a/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json b/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json index 11e2b3580eb..2c6aaed2e36 100644 --- a/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json +++ b/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/terminal", - "comment": "Add TerminalTable class: a drop-in replacement for the cli-table and cli-table3 npm packages that correctly handles ANSI escape sequences when calculating column widths.", + "comment": "Add `TerminalTable` class: a drop-in replacement for the `cli-table` and `cli-table3` npm packages that correctly handles ANSI escape sequences when calculating column widths.", "type": "minor" } ], From 3a5f52a60fd24da4ad97365a9b07590c94cc66a7 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 17:48:08 -0700 Subject: [PATCH 4/9] Rush update. --- .../build-tests-subspace/pnpm-lock.yaml | 25 ++++--------------- .../build-tests-subspace/repo-state.json | 4 +-- .../config/subspaces/default/pnpm-lock.yaml | 25 ------------------- .../config/subspaces/default/repo-state.json | 2 +- 4 files changed, 8 insertions(+), 48 deletions(-) diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 01ef2b881c7..e11eadee2b6 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -911,7 +911,7 @@ packages: '@rushstack/heft-api-extractor-plugin@file:../../../heft-plugins/heft-api-extractor-plugin': resolution: {directory: ../../../heft-plugins/heft-api-extractor-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.13 + '@rushstack/heft': 1.2.14 '@rushstack/heft-config-file@file:../../../libraries/heft-config-file': resolution: {directory: ../../../libraries/heft-config-file, type: directory} @@ -920,7 +920,7 @@ packages: '@rushstack/heft-jest-plugin@file:../../../heft-plugins/heft-jest-plugin': resolution: {directory: ../../../heft-plugins/heft-jest-plugin, type: directory} peerDependencies: - '@rushstack/heft': ^1.2.13 + '@rushstack/heft': ^1.2.14 '@types/jest': ^30.0.0 jest-environment-jsdom: ^30.3.0 jest-environment-node: ^30.3.0 @@ -935,17 +935,17 @@ packages: '@rushstack/heft-lint-plugin@file:../../../heft-plugins/heft-lint-plugin': resolution: {directory: ../../../heft-plugins/heft-lint-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.13 + '@rushstack/heft': 1.2.14 '@rushstack/heft-node-rig@file:../../../rigs/heft-node-rig': resolution: {directory: ../../../rigs/heft-node-rig, type: directory} peerDependencies: - '@rushstack/heft': ^1.2.13 + '@rushstack/heft': ^1.2.14 '@rushstack/heft-typescript-plugin@file:../../../heft-plugins/heft-typescript-plugin': resolution: {directory: ../../../heft-plugins/heft-typescript-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.13 + '@rushstack/heft': 1.2.14 '@rushstack/heft@file:../../../apps/heft': resolution: {directory: ../../../apps/heft, type: directory} @@ -1622,10 +1622,6 @@ packages: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} engines: {node: '>= 10.0'} - cli-table@0.3.11: - resolution: {integrity: sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==} - engines: {node: '>= 0.2.0'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -1656,10 +1652,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colors@1.0.3: - resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} - engines: {node: '>=0.1.90'} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4344,7 +4336,6 @@ snapshots: '@rushstack/ts-command-line': file:../../../libraries/ts-command-line(@types/node@20.17.19) '@yarnpkg/lockfile': 1.0.2 builtin-modules: 3.1.0 - cli-table: 0.3.11 dependency-path: 9.2.8 dotenv: 16.4.7 fast-glob: 3.3.3 @@ -5763,10 +5754,6 @@ snapshots: dependencies: source-map: 0.6.1 - cli-table@0.3.11: - dependencies: - colors: 1.0.3 - cli-width@4.1.0: {} cmd-extension@1.0.2: {} @@ -5789,8 +5776,6 @@ snapshots: color-name@1.1.4: {} - colors@1.0.3: {} - commander@2.20.3: {} commander@8.3.0: {} diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 9cdecb98535..05fdb80aa0a 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "67e1e1974c3a01231385fd6e8e3b892a4f3729dd", + "pnpmShrinkwrapHash": "72fae9b780cca1f45b7c807b24a587a13f1719e6", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "157a943794dbd83c14beb27a57db03705eb04aa6" + "packageJsonInjectedDependenciesHash": "258293487508f4a9172933cb6d0c90a02599bd8d" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 160aa315119..daaf5d41460 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4112,9 +4112,6 @@ importers: builtin-modules: specifier: ~3.1.0 version: 3.1.0 - cli-table: - specifier: ~0.3.1 - version: 0.3.11 dependency-path: specifier: ~9.2.8 version: 9.2.8 @@ -4191,9 +4188,6 @@ importers: '@rushstack/webpack-preserve-dynamic-require-plugin': specifier: workspace:* version: link:../../webpack/preserve-dynamic-require-plugin - '@types/cli-table': - specifier: 0.3.0 - version: 0.3.0 '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 @@ -10129,9 +10123,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/cli-table@0.3.0': - resolution: {integrity: sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ==} - '@types/color-convert@2.0.4': resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==} @@ -11924,10 +11915,6 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} - cli-table@0.3.11: - resolution: {integrity: sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==} - engines: {node: '>= 0.2.0'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -12011,10 +11998,6 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} - colors@1.0.3: - resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} - engines: {node: '>=0.1.90'} - colors@1.2.5: resolution: {integrity: sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==} engines: {node: '>=0.1.90'} @@ -26706,8 +26689,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/cli-table@0.3.0': {} - '@types/color-convert@2.0.4': dependencies: '@types/color-name': 1.1.5 @@ -29221,10 +29202,6 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 - cli-table@0.3.11: - dependencies: - colors: 1.0.3 - cli-width@4.1.0: {} cliui@6.0.0: @@ -29302,8 +29279,6 @@ snapshots: colorjs.io@0.5.2: {} - colors@1.0.3: {} - colors@1.2.5: {} combined-stream@1.0.8: diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index 26574216c73..ac09829bf15 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "e329020e3621437b0039842e5440d019c5315c14", + "pnpmShrinkwrapHash": "f41b01db5e94d65ef640cb5e6eb9dce1780f93c6", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } From 1f5323b9447fcb97b325ee1c825689296049755d Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 17:49:09 -0700 Subject: [PATCH 5/9] fixup! [terminal] Add TerminalTable; [rush-lib] replace cli-table dependency --- common/reviews/api/terminal.api.md | 48 +++++++++++++++++++ .../__snapshots__/TerminalTable.test.ts.snap | 44 +++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index 97c5691622f..e059ef40431 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -246,6 +246,47 @@ export interface ITerminalStreamWritableOptions { writableOptions?: WritableOptions; } +// @public +export interface ITerminalTableChars { + // (undocumented) + 'bottom-left': string; + // (undocumented) + 'bottom-mid': string; + // (undocumented) + 'bottom-right': string; + // (undocumented) + 'left-mid': string; + // (undocumented) + 'mid-mid': string; + // (undocumented) + 'right-mid': string; + // (undocumented) + 'top-left': string; + // (undocumented) + 'top-mid': string; + // (undocumented) + 'top-right': string; + // (undocumented) + bottom: string; + // (undocumented) + left: string; + // (undocumented) + mid: string; + // (undocumented) + middle: string; + // (undocumented) + right: string; + // (undocumented) + top: string; +} + +// @public +export interface ITerminalTableOptions { + chars?: Partial; + colWidths?: number[]; + head?: string[]; +} + // @public export interface ITerminalTransformOptions extends ITerminalWritableOptions { destination: TerminalWritable; @@ -459,6 +500,13 @@ export class TerminalStreamWritable extends Writable { _write(chunk: string | Buffer | Uint8Array, encoding: string, callback: (error?: Error | null) => void): void; } +// @public +export class TerminalTable { + constructor(options?: ITerminalTableOptions); + push(...rows: string[][]): void; + toString(): string; +} + // @public export abstract class TerminalTransform extends TerminalWritable { constructor(options: ITerminalTransformOptions); diff --git a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap new file mode 100644 index 00000000000..5dbee309f43 --- /dev/null +++ b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`TerminalTable auto-sizes columns to the widest content 1`] = ` +"┌───────┬────────────────────────┐ +│ A │ B │ +├───────┼────────────────────────┤ +│ short │ a very long value here │ +└───────┴────────────────────────┘" +`; + +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 without a header 1`] = ` +"┌─────┬─────┐ +│ foo │ bar │ +│ baz │ qux │ +└─────┴─────┘" +`; + +exports[`TerminalTable respects fixed colWidths 1`] = ` +"┌──────────┬────────┐ +│ hi │ there │ +└──────────┴────────┘" +`; + +exports[`TerminalTable strips ANSI codes when calculating column widths 1`] = ` +"┌────────────┐ +│ Package │ +├────────────┤ +│ my-package │ +└────────────┘" +`; + +exports[`TerminalTable supports empty border chars (invisible borders) 1`] = ` +" alpha beta g + longer text x y " +`; From b49fdbbf52e430174c7ab60613de63ee5666dd09 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 17:49:40 -0700 Subject: [PATCH 6/9] Add a borderless option. --- common/reviews/api/terminal.api.md | 1 + .../src/utilities/InteractiveUpgradeUI.ts | 22 ++------ libraries/terminal/src/TerminalTable.ts | 33 +++++++++++- .../terminal/src/test/TerminalTable.test.ts | 52 ++++++------------- .../__snapshots__/TerminalTable.test.ts.snap | 7 +++ 5 files changed, 60 insertions(+), 55 deletions(-) diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index e059ef40431..e794f945854 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -282,6 +282,7 @@ export interface ITerminalTableChars { // @public export interface ITerminalTableOptions { + borderless?: boolean; chars?: Partial; colWidths?: number[]; head?: string[]; diff --git a/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts b/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts index ce700893ce1..e9a699f159e 100644 --- a/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts +++ b/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts @@ -147,29 +147,15 @@ export const upgradeInteractive = async (pkgs: INpmCheckPackageSummary[]): Promi .filter(Boolean); const cliTable: TerminalTable = new TerminalTable({ - chars: { - top: '', - 'top-mid': '', - 'top-left': '', - 'top-right': '', - bottom: '', - 'bottom-mid': '', - 'bottom-left': '', - 'bottom-right': '', - left: '', - 'left-mid': '', - mid: '', - 'mid-mid': '', - right: '', - 'right-mid': '', - middle: ' ' - }, + borderless: true, colWidths: [50, 10, 3, 10, 100] }); for (const choice of choices) { if (typeof choice === 'object' && 'name' in choice) { - cliTable.push(choice.name); + // choice.name is string[] at this point (set by label()); it is only replaced + // with a string after the table is rendered below. + cliTable.push(choice.name as string[]); } } diff --git a/libraries/terminal/src/TerminalTable.ts b/libraries/terminal/src/TerminalTable.ts index a8cea446ca5..88335175303 100644 --- a/libraries/terminal/src/TerminalTable.ts +++ b/libraries/terminal/src/TerminalTable.ts @@ -43,13 +43,40 @@ export interface ITerminalTableOptions { */ colWidths?: number[]; + /** + * If `true`, all border and separator lines are suppressed. Columns are visually + * separated only by the built-in one-character left-padding of each cell. + * This is a convenient shorthand for setting every entry in `chars` to `''`. + */ + borderless?: boolean; + /** * Overrides for individual border characters. * Pass an empty string for any character to suppress that part of the border. + * Applied after `borderless`, so individual characters can be restored even in + * borderless mode. */ chars?: Partial; } +const BORDERLESS_CHARS: ITerminalTableChars = { + top: '', + 'top-mid': '', + 'top-left': '', + 'top-right': '', + bottom: '', + 'bottom-mid': '', + 'bottom-left': '', + 'bottom-right': '', + left: '', + 'left-mid': '', + mid: '', + 'mid-mid': '', + right: '', + 'right-mid': '', + middle: '' +}; + const DEFAULT_CHARS: ITerminalTableChars = { top: '─', 'top-mid': '┬', @@ -93,7 +120,11 @@ export class TerminalTable { public constructor(options?: ITerminalTableOptions) { this._head = options?.head ?? []; this._specifiedColWidths = options?.colWidths ?? []; - this._chars = { ...DEFAULT_CHARS, ...options?.chars }; + this._chars = { + ...DEFAULT_CHARS, + ...(options?.borderless ? BORDERLESS_CHARS : undefined), + ...options?.chars + }; this._rows = []; } diff --git a/libraries/terminal/src/test/TerminalTable.test.ts b/libraries/terminal/src/test/TerminalTable.test.ts index a92a6b930be..828d1a49314 100644 --- a/libraries/terminal/src/test/TerminalTable.test.ts +++ b/libraries/terminal/src/test/TerminalTable.test.ts @@ -37,25 +37,9 @@ describe(TerminalTable.name, () => { expect(row).toMatchSnapshot(); }); - it('supports empty border chars (invisible borders)', () => { + it('borderless: true suppresses all borders', () => { const table: TerminalTable = new TerminalTable({ - chars: { - top: '', - 'top-mid': '', - 'top-left': '', - 'top-right': '', - bottom: '', - 'bottom-mid': '', - 'bottom-left': '', - 'bottom-right': '', - left: '', - 'left-mid': '', - mid: '', - 'mid-mid': '', - right: '', - 'right-mid': '', - middle: ' ' - }, + borderless: true, colWidths: [10, 8, 6] }); table.push(['alpha', 'beta', 'g']); @@ -63,25 +47,9 @@ describe(TerminalTable.name, () => { expect(table.toString()).toMatchSnapshot(); }); - it('produces one line per row when borders are empty (for inquirer-style usage)', () => { + it('produces one line per row when borderless (for inquirer-style usage)', () => { const table: TerminalTable = new TerminalTable({ - chars: { - top: '', - 'top-mid': '', - 'top-left': '', - 'top-right': '', - bottom: '', - 'bottom-mid': '', - 'bottom-left': '', - 'bottom-right': '', - left: '', - 'left-mid': '', - mid: '', - 'mid-mid': '', - right: '', - 'right-mid': '', - middle: ' ' - }, + borderless: true, colWidths: [20, 10] }); table.push(['row one', 'v1']); @@ -91,6 +59,18 @@ describe(TerminalTable.name, () => { expect(lines.length).toBe(3); }); + it('chars overrides are applied on top of borderless', () => { + const table: TerminalTable = new TerminalTable({ + borderless: true, + chars: { middle: ' | ' }, + colWidths: [10, 8] + }); + table.push(['hello', 'world']); + const row: string = table.toString(); + expect(row).toContain(' | '); + expect(table.toString()).toMatchSnapshot(); + }); + 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 diff --git a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap index 5dbee309f43..01dece1c241 100644 --- a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap @@ -8,6 +8,13 @@ exports[`TerminalTable auto-sizes columns to the widest content 1`] = ` └───────┴────────────────────────┘" `; +exports[`TerminalTable borderless: true suppresses all borders 1`] = ` +" alpha beta g + longer text x y " +`; + +exports[`TerminalTable chars overrides are applied on top of borderless 1`] = `" hello | world "`; + exports[`TerminalTable renders a table with a header and rows 1`] = ` "┌─────────────────────┬─────────┐ │ Name │ Version │ From 9ec9b8ac839970df67cf2de8b4d17241d9097a25 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 18:07:55 -0700 Subject: [PATCH 7/9] [terminal] Redesign TerminalTable API: camelCase border names, borderless option, getLines() - Rename all ITerminalTableChars members from kebab-case to camelCase - Rename `chars` option to `borderCharacters` - Add `borderless: true` shorthand (replaces verbose empty-string chars override) - Add `getLines(): string[]` method - Add JSDoc to ITerminalTableChars members with visual reference diagram - Update tests and snapshots Co-Authored-By: Claude Sonnet 4.6 --- common/reviews/api/terminal.api.md | 34 +-- libraries/terminal/src/TerminalTable.ts | 215 +++++++++++------- .../terminal/src/test/TerminalTable.test.ts | 2 +- .../__snapshots__/TerminalTable.test.ts.snap | 5 - 4 files changed, 156 insertions(+), 100 deletions(-) diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index e794f945854..bb90d04c4f4 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -249,41 +249,41 @@ export interface ITerminalStreamWritableOptions { // @public export interface ITerminalTableChars { // (undocumented) - 'bottom-left': string; + bottom: string; // (undocumented) - 'bottom-mid': string; + bottomCenter: string; // (undocumented) - 'bottom-right': string; + bottomLeft: string; // (undocumented) - 'left-mid': string; + bottomRight: string; // (undocumented) - 'mid-mid': string; + centerCenter: string; // (undocumented) - 'right-mid': string; + horizontalCenter: string; // (undocumented) - 'top-left': string; + left: string; // (undocumented) - 'top-mid': string; + leftCenter: string; // (undocumented) - 'top-right': string; + right: string; // (undocumented) - bottom: string; + rightCenter: string; // (undocumented) - left: string; + top: string; // (undocumented) - mid: string; + topCenter: string; // (undocumented) - middle: string; + topLeft: string; // (undocumented) - right: string; + topRight: string; // (undocumented) - top: string; + verticalCenter: string; } // @public export interface ITerminalTableOptions { + borderCharacters?: Partial; borderless?: boolean; - chars?: Partial; colWidths?: number[]; head?: string[]; } @@ -504,6 +504,8 @@ export class TerminalStreamWritable extends Writable { // @public export class TerminalTable { constructor(options?: ITerminalTableOptions); + // (undocumented) + getLines(): string[]; push(...rows: string[][]): void; toString(): string; } diff --git a/libraries/terminal/src/TerminalTable.ts b/libraries/terminal/src/TerminalTable.ts index 88335175303..fd1100f6474 100644 --- a/libraries/terminal/src/TerminalTable.ts +++ b/libraries/terminal/src/TerminalTable.ts @@ -6,24 +6,48 @@ import { AnsiEscape } from './AnsiEscape'; /** * The set of characters used to draw table borders. * + * Visual reference (default Unicode box-drawing characters): + * ``` + * ┌─────────┬─────────┐ ← topLeft, top (fill), topCenter, topRight + * │ header │ header │ ← left, verticalCenter, right + * ├─────────┼─────────┤ ← leftCenter, horizontalCenter (fill), centerCenter, rightCenter + * │ data │ data │ ← left, verticalCenter, right + * └─────────┴─────────┘ ← bottomLeft, bottom (fill), bottomCenter, bottomRight + * ``` + * * @public */ export interface ITerminalTableChars { + /** Fill character for the top border row. Default: `─` */ top: string; - 'top-mid': string; - 'top-left': string; - 'top-right': string; + /** Junction where a column divider meets the top border. Default: `┬` */ + topCenter: string; + /** Top-left corner. Default: `┌` */ + topLeft: string; + /** Top-right corner. Default: `┐` */ + topRight: string; + /** Fill character for the bottom border row. Default: `─` */ bottom: string; - 'bottom-mid': string; - 'bottom-left': string; - 'bottom-right': string; + /** Junction where a column divider meets the bottom border. Default: `┴` */ + bottomCenter: string; + /** Bottom-left corner. Default: `└` */ + bottomLeft: string; + /** Bottom-right corner. Default: `┘` */ + bottomRight: string; + /** Left border character for data rows. Default: `│` */ left: string; - 'left-mid': string; - mid: string; - 'mid-mid': string; + /** Left end of the header/body separator row. Default: `├` */ + leftCenter: string; + /** Fill character for the header/body separator row. Default: `─` */ + horizontalCenter: string; + /** Junction where a column divider crosses the header/body separator. Default: `┼` */ + centerCenter: string; + /** Right border character for data rows. Default: `│` */ right: string; - 'right-mid': string; - middle: string; + /** Right end of the header/body separator row. Default: `┤` */ + rightCenter: string; + /** Column separator character within data rows. Default: `│` */ + verticalCenter: string; } /** @@ -56,43 +80,43 @@ export interface ITerminalTableOptions { * Applied after `borderless`, so individual characters can be restored even in * borderless mode. */ - chars?: Partial; + borderCharacters?: Partial; } const BORDERLESS_CHARS: ITerminalTableChars = { top: '', - 'top-mid': '', - 'top-left': '', - 'top-right': '', + topCenter: '', + topLeft: '', + topRight: '', bottom: '', - 'bottom-mid': '', - 'bottom-left': '', - 'bottom-right': '', + bottomCenter: '', + bottomLeft: '', + bottomRight: '', left: '', - 'left-mid': '', - mid: '', - 'mid-mid': '', + leftCenter: '', + horizontalCenter: '', + centerCenter: '', right: '', - 'right-mid': '', - middle: '' + rightCenter: '', + verticalCenter: '' }; const DEFAULT_CHARS: ITerminalTableChars = { top: '─', - 'top-mid': '┬', - 'top-left': '┌', - 'top-right': '┐', + topCenter: '┬', + topLeft: '┌', + topRight: '┐', bottom: '─', - 'bottom-mid': '┴', - 'bottom-left': '└', - 'bottom-right': '┘', + bottomCenter: '┴', + bottomLeft: '└', + bottomRight: '┘', left: '│', - 'left-mid': '├', - mid: '─', - 'mid-mid': '┼', + leftCenter: '├', + horizontalCenter: '─', + centerCenter: '┼', right: '│', - 'right-mid': '┤', - middle: '│' + rightCenter: '┤', + verticalCenter: '│' }; /** @@ -114,16 +138,16 @@ const DEFAULT_CHARS: ITerminalTableChars = { export class TerminalTable { private readonly _head: string[]; private readonly _specifiedColWidths: (number | undefined)[]; - private readonly _chars: ITerminalTableChars; + private readonly _borderCharacters: ITerminalTableChars; private readonly _rows: string[][]; - public constructor(options?: ITerminalTableOptions) { - this._head = options?.head ?? []; - this._specifiedColWidths = options?.colWidths ?? []; - this._chars = { - ...DEFAULT_CHARS, - ...(options?.borderless ? BORDERLESS_CHARS : undefined), - ...options?.chars + public constructor(options: ITerminalTableOptions = {}) { + const { head, colWidths, borderless, borderCharacters } = options; + this._head = head ?? []; + this._specifiedColWidths = colWidths ?? []; + this._borderCharacters = { + ...(borderless ? BORDERLESS_CHARS : DEFAULT_CHARS), + ...borderCharacters }; this._rows = []; } @@ -137,32 +161,55 @@ export class TerminalTable { } } - /** - * Renders the table to a string. - */ - public toString(): string { - const allRows: string[][] = this._head.length > 0 ? [this._head, ...this._rows] : this._rows; - const colCount: number = Math.max(0, ...allRows.map((r) => r.length)); - if (colCount === 0) { - return ''; + public getLines(): string[] { + const { + _head: head, + _rows: rows, + _specifiedColWidths: specifiedColWidths, + _borderCharacters: { + top: topSeparator, + topCenter: topCenterSeparator, + topLeft: topLeftSeparator, + topRight: topRightSeparator, + bottom: bottomSeparator, + bottomCenter: bottomCenterSeparator, + bottomLeft: bottomLeftSeparator, + bottomRight: bottomRightSeparator, + left: leftSeparator, + leftCenter: leftCenterSeparator, + horizontalCenter: horizontalCenterSeparator, + centerCenter: centerCenterSeparator, + right: rightSeparator, + rightCenter: rightCenterSeparator, + verticalCenter: verticalCenterSeparator + } + } = this; + + const allRows: string[][] = [head, ...rows]; + const columnCount: number = Math.max(0, ...allRows.map((r) => r.length)); + if (columnCount === 0) { + return []; } // Resolve final column widths: use specified width if provided, otherwise auto-size from content. - const colWidths: number[] = []; - for (let col: number = 0; col < colCount; col++) { - const specified: number | undefined = this._specifiedColWidths[col]; + const columnWidths: number[] = []; + for (let columnIndex: number = 0; columnIndex < columnCount; columnIndex++) { + const specified: number | undefined = specifiedColWidths[columnIndex]; if (specified !== undefined) { - colWidths.push(specified); + columnWidths.push(specified); } else { let maxContent: number = 0; for (const row of allRows) { - if (col < row.length) { - const w: number = AnsiEscape.removeCodes(row[col]).length; - if (w > maxContent) maxContent = w; + if (columnIndex < row.length) { + const width: number = AnsiEscape.removeCodes(row[columnIndex]).length; + if (width > maxContent) { + maxContent = width; + } } } + // +2 for one character of padding on each side - colWidths.push(maxContent + 2); + columnWidths.push(maxContent + 2); } } @@ -173,44 +220,48 @@ export class TerminalTable { midChar: string, rightChar: string ): string | undefined => { - const line: string = leftChar + colWidths.map((w) => fillChar.repeat(w)).join(midChar) + rightChar; + const line: string = leftChar + columnWidths.map((w) => fillChar.repeat(w)).join(midChar) + rightChar; return line.length > 0 ? line : undefined; }; // Renders a single data row. const renderRow = (row: string[]): string => { const cells: string[] = []; - for (let col: number = 0; col < colCount; col++) { + 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(colWidths[col] - 1 - visualWidth, 0); + const padRight: number = Math.max(columnWidths[col] - 1 - visualWidth, 0); cells.push(' ' + content + ' '.repeat(padRight)); } - return this._chars.left + cells.join(this._chars.middle) + this._chars.right; + return leftSeparator + cells.join(verticalCenterSeparator) + rightSeparator; }; const lines: string[] = []; // Top border const topLine: string | undefined = renderSeparator( - this._chars['top-left'], - this._chars.top, - this._chars['top-mid'], - this._chars['top-right'] + topLeftSeparator, + topSeparator, + topCenterSeparator, + topRightSeparator ); - if (topLine !== undefined) lines.push(topLine); + if (topLine !== undefined) { + lines.push(topLine); + } // Header row + separator - if (this._head.length > 0) { - lines.push(renderRow(this._head)); + if (head.length > 0) { + lines.push(renderRow(head)); const headerSep: string | undefined = renderSeparator( - this._chars['left-mid'], - this._chars.mid, - this._chars['mid-mid'], - this._chars['right-mid'] + leftCenterSeparator, + horizontalCenterSeparator, + centerCenterSeparator, + rightCenterSeparator ); - if (headerSep !== undefined) lines.push(headerSep); + if (headerSep !== undefined) { + lines.push(headerSep); + } } // Data rows (no separator between them) @@ -220,15 +271,23 @@ export class TerminalTable { // Bottom border const bottomLine: string | undefined = renderSeparator( - this._chars['bottom-left'], - this._chars.bottom, - this._chars['bottom-mid'], - this._chars['bottom-right'] + bottomLeftSeparator, + bottomSeparator, + bottomCenterSeparator, + bottomRightSeparator ); if (bottomLine !== undefined) { lines.push(bottomLine); } + return lines; + } + + /** + * Renders the table to a string. + */ + public toString(): string { + const lines: string[] = this.getLines(); return lines.join('\n'); } } diff --git a/libraries/terminal/src/test/TerminalTable.test.ts b/libraries/terminal/src/test/TerminalTable.test.ts index 828d1a49314..287e276eeff 100644 --- a/libraries/terminal/src/test/TerminalTable.test.ts +++ b/libraries/terminal/src/test/TerminalTable.test.ts @@ -62,7 +62,7 @@ describe(TerminalTable.name, () => { it('chars overrides are applied on top of borderless', () => { const table: TerminalTable = new TerminalTable({ borderless: true, - chars: { middle: ' | ' }, + borderCharacters: { verticalCenter: ' | ' }, colWidths: [10, 8] }); table.push(['hello', 'world']); diff --git a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap index 01dece1c241..6046e64bac4 100644 --- a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap @@ -44,8 +44,3 @@ exports[`TerminalTable strips ANSI codes when calculating column widths 1`] = ` │ my-package │ └────────────┘" `; - -exports[`TerminalTable supports empty border chars (invisible borders) 1`] = ` -" alpha beta g - longer text x y " -`; From b05436c6e02f894839d7a0640e85fc5dc315718c Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 18:09:10 -0700 Subject: [PATCH 8/9] fixup! [terminal] Redesign TerminalTable API: camelCase border names, borderless option, getLines() --- common/reviews/api/terminal.api.md | 15 --------------- .../src/utilities/InteractiveUpgradeUI.ts | 2 +- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index bb90d04c4f4..07366360713 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -248,35 +248,20 @@ export interface ITerminalStreamWritableOptions { // @public export interface ITerminalTableChars { - // (undocumented) bottom: string; - // (undocumented) bottomCenter: string; - // (undocumented) bottomLeft: string; - // (undocumented) bottomRight: string; - // (undocumented) centerCenter: string; - // (undocumented) horizontalCenter: string; - // (undocumented) left: string; - // (undocumented) leftCenter: string; - // (undocumented) right: string; - // (undocumented) rightCenter: string; - // (undocumented) top: string; - // (undocumented) topCenter: string; - // (undocumented) topLeft: string; - // (undocumented) topRight: string; - // (undocumented) verticalCenter: string; } diff --git a/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts b/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts index e9a699f159e..701e0a5b8e1 100644 --- a/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts +++ b/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts @@ -159,7 +159,7 @@ export const upgradeInteractive = async (pkgs: INpmCheckPackageSummary[]): Promi } } - const choicesAsATable: string[] = cliTable.toString().split('\n'); + const choicesAsATable: string[] = cliTable.getLines(); for (let i: number = 0; i < choices.length; i++) { const choice: IUpgradeInteractiveDepChoice | Separator | boolean | undefined = choices[i]; if (typeof choice === 'object' && 'name' in choice) { From 1db8842577b6cab8a7e3a4906cc5405159fbdf7a Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Fri, 17 Apr 2026 18:10:54 -0700 Subject: [PATCH 9/9] fixup! Add changefiles for terminal-table PR --- .../changes/@rushstack/terminal/terminal-table_2026-04-17.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json b/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json index 2c6aaed2e36..8f45b59351c 100644 --- a/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json +++ b/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/terminal", - "comment": "Add `TerminalTable` class: a drop-in replacement for the `cli-table` and `cli-table3` npm packages that correctly handles ANSI escape sequences when calculating column widths.", + "comment": "Add `TerminalTable` class for rendering fixed-column tables in terminal output, with correct handling of ANSI escape sequences when calculating column widths.", "type": "minor" } ],