From a98d96ef0414833b948672f86da4acc11f700ebb Mon Sep 17 00:00:00 2001 From: Ben Coombs Date: Tue, 14 Oct 2025 19:08:11 +0100 Subject: [PATCH] fix: standardize UI box width calculations across components (#1305) Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> --- .changeset/fix-warning-box-alignment.md | 5 + .../src/ui/components/next-task.component.ts | 4 +- .../components/suggested-steps.component.ts | 3 +- apps/cli/src/utils/ui.spec.ts | 158 ++++++++++++++++++ apps/cli/src/utils/ui.ts | 58 +++++-- 5 files changed, 209 insertions(+), 19 deletions(-) create mode 100644 .changeset/fix-warning-box-alignment.md create mode 100644 apps/cli/src/utils/ui.spec.ts diff --git a/.changeset/fix-warning-box-alignment.md b/.changeset/fix-warning-box-alignment.md new file mode 100644 index 00000000..fd5b20c9 --- /dev/null +++ b/.changeset/fix-warning-box-alignment.md @@ -0,0 +1,5 @@ +--- +"@tm/cli": patch +--- + +Fix warning message box width to match dashboard box width for consistent UI alignment diff --git a/apps/cli/src/ui/components/next-task.component.ts b/apps/cli/src/ui/components/next-task.component.ts index 91822a45..9de81c28 100644 --- a/apps/cli/src/ui/components/next-task.component.ts +++ b/apps/cli/src/ui/components/next-task.component.ts @@ -6,7 +6,7 @@ import chalk from 'chalk'; import boxen from 'boxen'; import type { Task } from '@tm/core/types'; -import { getComplexityWithColor } from '../../utils/ui.js'; +import { getComplexityWithColor, getBoxWidth } from '../../utils/ui.js'; /** * Next task display options @@ -113,7 +113,7 @@ export function displayRecommendedNextTask( borderColor: '#FFA500', // Orange color title: chalk.hex('#FFA500')('⚡ RECOMMENDED NEXT TASK ⚡'), titleAlignment: 'center', - width: process.stdout.columns * 0.97, + width: getBoxWidth(0.97), fullscreen: false }) ); diff --git a/apps/cli/src/ui/components/suggested-steps.component.ts b/apps/cli/src/ui/components/suggested-steps.component.ts index 66e5eb19..c3b6d254 100644 --- a/apps/cli/src/ui/components/suggested-steps.component.ts +++ b/apps/cli/src/ui/components/suggested-steps.component.ts @@ -5,6 +5,7 @@ import chalk from 'chalk'; import boxen from 'boxen'; +import { getBoxWidth } from '../../utils/ui.js'; /** * Display suggested next steps section @@ -24,7 +25,7 @@ export function displaySuggestedNextSteps(): void { margin: { top: 0, bottom: 1 }, borderStyle: 'round', borderColor: 'gray', - width: process.stdout.columns * 0.97 + width: getBoxWidth(0.97) } ) ); diff --git a/apps/cli/src/utils/ui.spec.ts b/apps/cli/src/utils/ui.spec.ts new file mode 100644 index 00000000..2c553f8c --- /dev/null +++ b/apps/cli/src/utils/ui.spec.ts @@ -0,0 +1,158 @@ +/** + * CLI UI utilities tests + * Tests for apps/cli/src/utils/ui.ts + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { getBoxWidth } from './ui.js'; + +describe('CLI UI Utilities', () => { + describe('getBoxWidth', () => { + let columnsSpy: MockInstance; + let originalDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + // Store original descriptor if it exists + originalDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + 'columns' + ); + + // If columns doesn't exist or isn't a getter, define it as one + if (!originalDescriptor || !originalDescriptor.get) { + const currentValue = process.stdout.columns || 80; + Object.defineProperty(process.stdout, 'columns', { + get() { + return currentValue; + }, + configurable: true + }); + } + + // Now spy on the getter + columnsSpy = vi.spyOn(process.stdout, 'columns', 'get'); + }); + + afterEach(() => { + // Restore the spy + columnsSpy.mockRestore(); + + // Restore original descriptor or delete the property + if (originalDescriptor) { + Object.defineProperty(process.stdout, 'columns', originalDescriptor); + } else { + delete (process.stdout as any).columns; + } + }); + + it('should calculate width as percentage of terminal width', () => { + columnsSpy.mockReturnValue(100); + const width = getBoxWidth(0.9, 40); + expect(width).toBe(90); + }); + + it('should use default percentage of 0.9 when not specified', () => { + columnsSpy.mockReturnValue(100); + const width = getBoxWidth(); + expect(width).toBe(90); + }); + + it('should use default minimum width of 40 when not specified', () => { + columnsSpy.mockReturnValue(30); + const width = getBoxWidth(); + expect(width).toBe(40); // Should enforce minimum + }); + + it('should enforce minimum width when terminal is too narrow', () => { + columnsSpy.mockReturnValue(50); + const width = getBoxWidth(0.9, 60); + expect(width).toBe(60); // Should use minWidth instead of 45 + }); + + it('should handle undefined process.stdout.columns', () => { + columnsSpy.mockReturnValue(undefined); + const width = getBoxWidth(0.9, 40); + // Should fall back to 80 columns: Math.floor(80 * 0.9) = 72 + expect(width).toBe(72); + }); + + it('should handle custom percentage values', () => { + columnsSpy.mockReturnValue(100); + expect(getBoxWidth(0.95, 40)).toBe(95); + expect(getBoxWidth(0.8, 40)).toBe(80); + expect(getBoxWidth(0.5, 40)).toBe(50); + }); + + it('should handle custom minimum width values', () => { + columnsSpy.mockReturnValue(60); + expect(getBoxWidth(0.9, 70)).toBe(70); // 60 * 0.9 = 54, but min is 70 + expect(getBoxWidth(0.9, 50)).toBe(54); // 60 * 0.9 = 54, min is 50 + }); + + it('should floor the calculated width', () => { + columnsSpy.mockReturnValue(99); + const width = getBoxWidth(0.9, 40); + // 99 * 0.9 = 89.1, should floor to 89 + expect(width).toBe(89); + }); + + it('should match warning box width calculation', () => { + // Test the specific case from displayWarning() + columnsSpy.mockReturnValue(80); + const width = getBoxWidth(0.9, 40); + expect(width).toBe(72); + }); + + it('should match table width calculation', () => { + // Test the specific case from createTaskTable() + columnsSpy.mockReturnValue(111); + const width = getBoxWidth(0.9, 100); + // 111 * 0.9 = 99.9, floor to 99, but max(99, 100) = 100 + expect(width).toBe(100); + }); + + it('should match recommended task box width calculation', () => { + // Test the specific case from displayRecommendedNextTask() + columnsSpy.mockReturnValue(120); + const width = getBoxWidth(0.97, 40); + // 120 * 0.97 = 116.4, floor to 116 + expect(width).toBe(116); + }); + + it('should handle edge case of zero terminal width', () => { + columnsSpy.mockReturnValue(0); + const width = getBoxWidth(0.9, 40); + // When columns is 0, it uses fallback of 80: Math.floor(80 * 0.9) = 72 + expect(width).toBe(72); + }); + + it('should handle very large terminal widths', () => { + columnsSpy.mockReturnValue(1000); + const width = getBoxWidth(0.9, 40); + expect(width).toBe(900); + }); + + it('should handle very small percentages', () => { + columnsSpy.mockReturnValue(100); + const width = getBoxWidth(0.1, 5); + // 100 * 0.1 = 10, which is greater than min 5 + expect(width).toBe(10); + }); + + it('should handle percentage of 1.0 (100%)', () => { + columnsSpy.mockReturnValue(80); + const width = getBoxWidth(1.0, 40); + expect(width).toBe(80); + }); + + it('should consistently return same value for same inputs', () => { + columnsSpy.mockReturnValue(100); + const width1 = getBoxWidth(0.9, 40); + const width2 = getBoxWidth(0.9, 40); + const width3 = getBoxWidth(0.9, 40); + expect(width1).toBe(width2); + expect(width2).toBe(width3); + }); + }); +}); diff --git a/apps/cli/src/utils/ui.ts b/apps/cli/src/utils/ui.ts index 533bfb0f..048cf343 100644 --- a/apps/cli/src/utils/ui.ts +++ b/apps/cli/src/utils/ui.ts @@ -126,6 +126,20 @@ export function getComplexityWithScore(complexity: number | undefined): string { return color(`${complexity}/10 (${label})`); } +/** + * Calculate box width as percentage of terminal width + * @param percentage - Percentage of terminal width to use (default: 0.9) + * @param minWidth - Minimum width to enforce (default: 40) + * @returns Calculated box width + */ +export function getBoxWidth( + percentage: number = 0.9, + minWidth: number = 40 +): number { + const terminalWidth = process.stdout.columns || 80; + return Math.max(Math.floor(terminalWidth * percentage), minWidth); +} + /** * Truncate text to specified length */ @@ -176,6 +190,8 @@ export function displayBanner(title: string = 'Task Master'): void { * Display an error message (matches scripts/modules/ui.js style) */ export function displayError(message: string, details?: string): void { + const boxWidth = getBoxWidth(); + console.error( boxen( chalk.red.bold('X Error: ') + @@ -184,7 +200,8 @@ export function displayError(message: string, details?: string): void { { padding: 1, borderStyle: 'round', - borderColor: 'red' + borderColor: 'red', + width: boxWidth } ) ); @@ -194,13 +211,16 @@ export function displayError(message: string, details?: string): void { * Display a success message */ export function displaySuccess(message: string): void { + const boxWidth = getBoxWidth(); + console.log( boxen( chalk.green.bold(String.fromCharCode(8730) + ' ') + chalk.white(message), { padding: 1, borderStyle: 'round', - borderColor: 'green' + borderColor: 'green', + width: boxWidth } ) ); @@ -210,11 +230,14 @@ export function displaySuccess(message: string): void { * Display a warning message */ export function displayWarning(message: string): void { + const boxWidth = getBoxWidth(); + console.log( boxen(chalk.yellow.bold('⚠ ') + chalk.white(message), { padding: 1, borderStyle: 'round', - borderColor: 'yellow' + borderColor: 'yellow', + width: boxWidth }) ); } @@ -223,11 +246,14 @@ export function displayWarning(message: string): void { * Display info message */ export function displayInfo(message: string): void { + const boxWidth = getBoxWidth(); + console.log( boxen(chalk.blue.bold('i ') + chalk.white(message), { padding: 1, borderStyle: 'round', - borderColor: 'blue' + borderColor: 'blue', + width: boxWidth }) ); } @@ -282,23 +308,23 @@ export function createTaskTable( } = options || {}; // Calculate dynamic column widths based on terminal width - const terminalWidth = process.stdout.columns * 0.9 || 100; + const tableWidth = getBoxWidth(0.9, 100); // Adjust column widths to better match the original layout const baseColWidths = showComplexity ? [ - Math.floor(terminalWidth * 0.1), - Math.floor(terminalWidth * 0.4), - Math.floor(terminalWidth * 0.15), - Math.floor(terminalWidth * 0.1), - Math.floor(terminalWidth * 0.2), - Math.floor(terminalWidth * 0.1) + Math.floor(tableWidth * 0.1), + Math.floor(tableWidth * 0.4), + Math.floor(tableWidth * 0.15), + Math.floor(tableWidth * 0.1), + Math.floor(tableWidth * 0.2), + Math.floor(tableWidth * 0.1) ] // ID, Title, Status, Priority, Dependencies, Complexity : [ - Math.floor(terminalWidth * 0.08), - Math.floor(terminalWidth * 0.4), - Math.floor(terminalWidth * 0.18), - Math.floor(terminalWidth * 0.12), - Math.floor(terminalWidth * 0.2) + Math.floor(tableWidth * 0.08), + Math.floor(tableWidth * 0.4), + Math.floor(tableWidth * 0.18), + Math.floor(tableWidth * 0.12), + Math.floor(tableWidth * 0.2) ]; // ID, Title, Status, Priority, Dependencies const headers = [