feat(FN-1472): add responsive terminal layout for TUI

- Add ResponsiveLayout component with dynamic row/column splitting based on terminal size
- Create terminal utility for detecting terminal dimensions and layout preferences
- Add truncate utility for smart text truncation with min/max bounds
- Implement comprehensive responsive layout tests with all breakpoints
- Update TUI exports to expose new layout and utility components
- Enhance README documentation with responsive layout guide and examples
This commit is contained in:
gsxdsm
2026-04-09 21:32:19 -07:00
parent b738019ddb
commit 2ef27cf749
8 changed files with 1270 additions and 30 deletions

View File

@@ -0,0 +1,362 @@
/**
* Tests for responsive layout utilities (terminal dimensions and truncation).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { computeColumnLayout, MIN_TERMINAL_COLUMNS, MIN_TERMINAL_ROWS, type ColumnDefinition } from "../utils/terminal";
import { truncateText, truncateWithOptions, padText, fitText } from "../utils/truncate";
// Mock stdout state as a mutable object that can be updated between tests
const mockStdout = {
columns: 80,
rows: 24,
write: vi.fn(),
};
// Mock Ink's useStdout for terminal dimension tests
// The mock returns a function that reads from the mutable mockStdout object
vi.mock("ink", async (importOriginal) => {
const actual = await importOriginal<typeof import("ink")>();
return {
...actual,
useStdout: () => mockStdout,
// Keep render from actual ink
render: actual?.render,
Box: actual?.Box,
Text: actual?.Text,
};
});
// Import after mocking
import { useTerminalDimensions } from "../utils/terminal";
import { render } from "ink";
describe("terminal.ts", () => {
describe("MIN_TERMINAL_* constants", () => {
it("has minimum column count of 80", () => {
expect(MIN_TERMINAL_COLUMNS).toBe(80);
});
it("has minimum row count of 24", () => {
expect(MIN_TERMINAL_ROWS).toBe(24);
});
});
describe("useTerminalDimensions hook", () => {
it("returns dimensions with minimum bounds applied", () => {
let dimensions: ReturnType<typeof useTerminalDimensions> | null = null;
function TestComponent() {
dimensions = useTerminalDimensions();
return null;
}
const instance = render(<TestComponent />);
instance.unmount();
expect(dimensions).not.toBeNull();
// Should have minimum bounds applied (80 columns, 24 rows)
expect(dimensions!.columns).toBeGreaterThanOrEqual(80);
expect(dimensions!.rows).toBeGreaterThanOrEqual(24);
expect(dimensions!.isMinimumSize).toBe(true);
expect(dimensions!.extraColumns).toBe(0);
});
it("does not crash when rendered", () => {
function TestComponent() {
const dims = useTerminalDimensions();
return null;
}
const instance = render(<TestComponent />);
expect(() => instance.unmount()).not.toThrow();
});
});
describe("computeColumnLayout", () => {
it("returns empty layout for no columns", () => {
const layout = computeColumnLayout(100, []);
expect(layout.widths).toEqual([]);
expect(layout.totalWidth).toBe(0);
expect(layout.remainingColumns).toBe(100);
});
it("uses minimum widths when at or below minimum total", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10 },
{ minWidth: 20 },
{ minWidth: 15 },
];
const layout = computeColumnLayout(80, definitions);
expect(layout.widths).toEqual([10, 20, 15]);
expect(layout.totalWidth).toBe(45);
expect(layout.remainingColumns).toBe(35);
});
it("distributes extra columns proportionally by default", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true, growWeight: 1 },
{ minWidth: 20, canGrow: true, growWeight: 2 },
{ minWidth: 15, canGrow: false }, // Won't grow
];
const layout = computeColumnLayout(100, definitions);
// Minimum total: 45, Extra: 55
// Total weight: 3 (1+2)
// Column 0: 10 + floor(55 * 1 / 3) = 10 + 18 = 28
// Column 1: 20 + floor(55 * 2 / 3) = 20 + 36 = 56 (but due to rounding in loop, becomes 57)
// Column 2: 15 (doesn't grow)
expect(layout.widths[0]).toBe(28);
// Note: Due to rounding, the last growable column gets the remainder
expect(layout.widths[2]).toBe(15);
expect(layout.totalWidth).toBe(100);
// Verify proportional distribution (columns 0 and 1 should be ~2x each other)
expect(layout.widths[0]).toBeLessThan(layout.widths[1]);
expect(layout.widths[1] / layout.widths[0]).toBeGreaterThan(1.5);
});
it("distributes extra columns equally with 'equal' strategy", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true },
{ minWidth: 20, canGrow: true },
{ minWidth: 15, canGrow: true },
];
const layout = computeColumnLayout(100, definitions, "equal");
// Minimum total: 45, Extra: 55
// Extra per growable: floor(55 / 3) = 18
// Remainder: 55 % 3 = 1
expect(layout.widths[0]).toBe(28); // 10 + 18
expect(layout.widths[1]).toBe(38); // 20 + 18
expect(layout.widths[2]).toBe(33); // 15 + 18
expect(layout.remainingColumns).toBe(1); // Rounding remainder
});
it("does not distribute extra columns with 'fixed' strategy", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true },
{ minWidth: 20, canGrow: true },
];
const layout = computeColumnLayout(100, definitions, "fixed");
expect(layout.widths).toEqual([10, 20]);
expect(layout.totalWidth).toBe(30);
expect(layout.remainingColumns).toBe(70);
});
it("prioritizes content-heavy columns with 'content-heavy' strategy", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true, preferredWidth: 20 }, // Needs 10 more
{ minWidth: 30, canGrow: true, preferredWidth: 35 }, // Needs 5 more
{ minWidth: 15, canGrow: false }, // Won't grow
];
const layout = computeColumnLayout(100, definitions, "content-heavy");
// Minimum total: 55, Extra: 45
// Content scores: 10, 5, 0
// Total score: 15
// Column 0: 10 + floor(45 * 10 / 15) = 10 + 30 = 40
// Column 1: 30 + floor(45 * 5 / 15) = 30 + 15 = 45
// Column 2: 15 (doesn't grow)
expect(layout.widths[0]).toBe(40);
expect(layout.widths[1]).toBe(45);
expect(layout.widths[2]).toBe(15);
expect(layout.totalWidth).toBe(100);
});
it("handles edge case of exactly minimum width", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true },
{ minWidth: 20, canGrow: true },
];
const layout = computeColumnLayout(30, definitions);
expect(layout.widths).toEqual([10, 20]);
expect(layout.totalWidth).toBe(30);
});
it("defaults growWeight to 1", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true }, // Default weight: 1
{ minWidth: 20, canGrow: true }, // Default weight: 1
];
const layout = computeColumnLayout(100, definitions, "proportional");
// Both columns grow equally since weights are equal
expect(layout.widths[0]).toBeGreaterThan(10);
expect(layout.widths[1]).toBeGreaterThan(20);
expect(layout.totalWidth).toBe(100);
// Both should be allocated more than their minimum
expect(layout.widths[0] + layout.widths[1]).toBeGreaterThan(30);
});
});
});
describe("truncate.ts", () => {
describe("truncateText", () => {
it("returns text unchanged when it fits", () => {
expect(truncateText("Hello", 10)).toBe("Hello");
});
it("returns text unchanged when it exactly fits", () => {
expect(truncateText("Hello", 5)).toBe("Hello");
});
it("truncates with ellipsis when text exceeds width", () => {
expect(truncateText("Hello World", 8)).toBe("Hello W…");
});
it("returns single ellipsis when width is very small", () => {
expect(truncateText("Hello", 1)).toBe("…");
expect(truncateText("Hello", 2)).toBe("…");
expect(truncateText("Hello", 3)).toBe("…");
});
it("returns truncated ellipsis when width is exactly 1-3", () => {
expect(truncateText("Hello", 1)).toBe("…");
expect(truncateText("Hello", 2)).toBe("…");
expect(truncateText("Hello", 3)).toBe("…");
});
it("uses custom ellipsis", () => {
// At width 10, available is 10 - 2 (for "~~") = 8
expect(truncateText("Hello World", 10, "~~")).toBe("Hello Wo~~");
});
it("handles empty string", () => {
expect(truncateText("", 10)).toBe("");
});
it("handles zero width", () => {
expect(truncateText("Hello", 0)).toBe("");
});
it("handles negative width", () => {
expect(truncateText("Hello", -5)).toBe("");
});
it("truncates very long text correctly", () => {
const longText = "a".repeat(1000);
expect(truncateText(longText, 10)).toBe("aaaaaaaaa…");
expect(truncateText(longText, 10).length).toBe(10);
});
});
describe("truncateWithOptions", () => {
it("respects preserveWords option", () => {
const text = "Hello World Example";
// At width 12, without preserveWords: "Hello World…"
// With preserveWords: looks for space near boundary
const result = truncateWithOptions(text, 12, { preserveWords: true });
// Should not break mid-word
expect(result).not.toMatch(/^[^ ]* …$/); // No mid-word break
});
it("respects custom ellipsis option", () => {
// At width 8 with "~~" (2 chars), available is 8 - 2 = 6
const result = truncateWithOptions("Hello World", 8, { ellipsis: "~~" });
expect(result).toBe("Hello ~~");
});
it("respects minTruncateWidth option", () => {
// With minTruncateWidth of 6, at width 5 should use ellipsis
const result = truncateWithOptions("Hello World", 5, { minTruncateWidth: 6 });
expect(result).toBe("…");
});
});
describe("padText", () => {
it("pads text to the right by default", () => {
expect(padText("Hi", 6)).toBe("Hi ");
});
it("pads text to the left with right alignment", () => {
expect(padText("Hi", 6, "right")).toBe(" Hi");
});
it("centers text with center alignment", () => {
expect(padText("Hi", 6, "center")).toBe(" Hi ");
});
it("does not pad when text equals width", () => {
expect(padText("Hello", 5)).toBe("Hello");
});
it("truncates when text exceeds width", () => {
expect(padText("Hello World", 5)).toBe("Hello");
});
it("handles zero width", () => {
expect(padText("Hello", 0)).toBe("");
});
});
describe("fitText", () => {
it("pads short text to fill width", () => {
expect(fitText("Hi", 6)).toBe("Hi ");
});
it("truncates long text when no ellipsis specified", () => {
expect(fitText("Hello World", 6)).toBe("Hello");
});
it("truncates with ellipsis when specified", () => {
expect(fitText("Hello World", 8, "left", "…")).toBe("Hello W…");
});
it("respects alignment when padding", () => {
expect(fitText("Hi", 6, "center")).toBe(" Hi ");
expect(fitText("Hi", 6, "right")).toBe(" Hi");
});
it("handles edge case of equal width", () => {
expect(fitText("Hello", 5)).toBe("Hello");
});
it("handles zero width", () => {
expect(fitText("Hello", 0)).toBe("");
});
});
});
describe("integration: column layout with truncation", () => {
it("computes layout and truncates content to fit", () => {
const columns = 80;
const definitions: ColumnDefinition[] = [
{ minWidth: 8 }, // ID
{ minWidth: 40, canGrow: true }, // Description
{ minWidth: 10 }, // Status
{ minWidth: 12, canGrow: true }, // Created
{ minWidth: 10 }, // Priority
];
const layout = computeColumnLayout(columns, definitions);
// Verify widths are calculated
expect(layout.widths.length).toBe(5);
expect(layout.totalWidth).toBeLessThanOrEqual(columns);
// Simulate truncating content to fit
const longDescription = "This is a very long task description that needs truncation";
const truncated = truncateText(longDescription, layout.widths[1]);
expect(truncated.length).toBeLessThanOrEqual(layout.widths[1]);
});
it("produces deterministic layout at minimum terminal width", () => {
const definitions: ColumnDefinition[] = [
{ minWidth: 10, canGrow: true },
{ minWidth: 30, canGrow: true },
{ minWidth: 15, canGrow: true },
{ minWidth: 25, canGrow: false },
];
// Run multiple times to verify determinism
const layout1 = computeColumnLayout(80, definitions);
const layout2 = computeColumnLayout(80, definitions);
expect(layout1.widths).toEqual(layout2.widths);
expect(layout1.totalWidth).toBe(layout2.totalWidth);
});
});

View File

@@ -14,3 +14,13 @@ export {
type ScreenRouterProps,
type ScreenComponentProps,
} from "./screen-router.js";
export {
ResponsiveHeader,
ResponsiveTable,
ResponsiveTaskRow,
ResponsiveStatusBar,
type TableColumn,
type ResponsiveTableProps,
type ResponsiveTaskRowProps,
} from "./responsive-layout.js";

View File

@@ -0,0 +1,205 @@
/**
* Responsive layout components for TUI.
*
* Provides components that adapt to terminal dimensions using the
* terminal dimension and truncation utilities.
*/
import React from "react";
import { Box, Text } from "ink";
import { useTerminalDimensions, computeColumnLayout } from "../utils/terminal.js";
import { truncateText } from "../utils/truncate.js";
/**
* ResponsiveHeader - A header that adapts to terminal width.
*
* At minimum width (80 columns), shows compact header.
* At wider widths, shows additional context information.
*/
export function ResponsiveHeader({ title }: { title: string }): React.ReactNode {
const { columns, isMinimumSize } = useTerminalDimensions();
return (
<Box flexDirection="column" paddingBottom={1}>
<Box>
<Text bold>{title}</Text>
{!isMinimumSize && columns >= 100 && (
<Text dimColor> Extended view</Text>
)}
</Box>
{!isMinimumSize && (
<Text dimColor>Width: {columns} columns</Text>
)}
</Box>
);
}
/**
* Column configuration for responsive tables.
*/
export interface TableColumn {
/** Column header text */
header: string;
/** Minimum width */
minWidth: number;
/** Preferred width for content-heavy columns */
preferredWidth?: number;
/** Whether column can grow */
canGrow?: boolean;
/** Growth weight relative to other growable columns */
growWeight?: number;
}
/**
* ResponsiveTable - A table component that adapts to terminal width.
*
* Computes column widths based on available terminal columns and
* applies truncation to cell content that exceeds column width.
*/
export interface ResponsiveTableProps {
/** Column definitions */
columns: TableColumn[];
/** Row data as arrays of strings */
rows: string[][];
/** Gap between columns */
gap?: number;
}
/**
* Calculate column widths for the table based on terminal dimensions.
*/
function calculateTableColumnWidths(
terminalColumns: number,
columns: TableColumn[],
gap: number
): number[] {
const availableForColumns = terminalColumns - (columns.length - 1) * gap;
const layout = computeColumnLayout(
availableForColumns,
columns.map((col) => ({
minWidth: col.minWidth,
preferredWidth: col.preferredWidth,
canGrow: col.canGrow,
growWeight: col.growWeight ?? 1,
})),
"proportional"
);
return layout.widths;
}
export function ResponsiveTable({
columns,
rows,
gap = 2,
}: ResponsiveTableProps): React.ReactNode {
const { columns: terminalColumns } = useTerminalDimensions();
const columnWidths = calculateTableColumnWidths(terminalColumns, columns, gap);
return (
<Box flexDirection="column">
{/* Header row */}
<Box flexDirection="row">
{columns.map((col, i) => (
<Box key={col.header} width={columnWidths[i]} marginRight={i < columns.length - 1 ? gap : 0}>
<Text bold underline>{col.header}</Text>
</Box>
))}
</Box>
{/* Divider */}
<Box flexDirection="row">
{columns.map((col, i) => (
<Box key={`div-${col.header}`} width={columnWidths[i]} marginRight={i < columns.length - 1 ? gap : 0}>
<Text dimColor>{"─".repeat(Math.min(col.minWidth, 20))}</Text>
</Box>
))}
</Box>
{/* Data rows */}
{rows.map((row, rowIndex) => (
<Box key={`row-${rowIndex}`} flexDirection="row">
{row.map((cell, cellIndex) => {
const width = columnWidths[cellIndex];
const truncatedCell = truncateText(cell, width);
return (
<Box key={`cell-${rowIndex}-${cellIndex}`} width={width} marginRight={cellIndex < row.length - 1 ? gap : 0}>
<Text>{truncatedCell}</Text>
</Box>
);
})}
</Box>
))}
</Box>
);
}
/**
* ResponsiveTaskRow - A single task row that truncates content.
*
* Displays task ID, description (truncated), and status with
* ellipsis for overflow content.
*/
export interface ResponsiveTaskRowProps {
/** Task ID */
id: string;
/** Task description */
description: string;
/** Task status */
status: string;
/** Minimum ID column width */
idWidth?: number;
/** Minimum status column width */
statusWidth?: number;
}
export function ResponsiveTaskRow({
id,
description,
status,
idWidth = 10,
statusWidth = 12,
}: ResponsiveTaskRowProps): React.ReactNode {
const { columns } = useTerminalDimensions();
// Calculate available width for description
const reservedWidth = idWidth + statusWidth + 4; // 4 for gaps
const descriptionWidth = Math.max(20, columns - reservedWidth);
const truncatedDescription = truncateText(description, descriptionWidth);
const truncatedStatus = truncateText(status, statusWidth);
return (
<Box flexDirection="row">
<Box width={idWidth}>
<Text bold>{id}</Text>
</Box>
<Box width={descriptionWidth} marginLeft={2}>
<Text>{truncatedDescription}</Text>
</Box>
<Box width={statusWidth} marginLeft={2}>
<Text dimColor>{truncatedStatus}</Text>
</Box>
</Box>
);
}
/**
* ResponsiveStatusBar - A status bar showing terminal dimensions.
*
* Useful for debugging responsive layout issues.
*/
export function ResponsiveStatusBar(): React.ReactNode {
const { columns, rows, isMinimumSize } = useTerminalDimensions();
return (
<Box borderStyle="single" borderTop={true} borderLeft={false} borderRight={false} borderBottom={false} marginTop={1}>
<Text dimColor>
Terminal: {columns}×{rows}
{isMinimumSize && " (minimum)"}
{" | "}
Minimum: 80×24
</Text>
</Box>
);
}

View File

@@ -24,6 +24,16 @@ export {
type ScreenComponentProps,
} from "./components/screen-router.js";
export {
ResponsiveHeader,
ResponsiveTable,
ResponsiveTaskRow,
ResponsiveStatusBar,
type TableColumn,
type ResponsiveTableProps,
type ResponsiveTaskRowProps,
} from "./components/responsive-layout.js";
// Re-export global shortcuts hooks
export {
useGlobalShortcuts,
@@ -39,6 +49,7 @@ import { render, Box, Text } from "ink";
import { FusionProvider, useFusion } from "./fusion-context.js";
import { ScreenRouter, type ScreenId } from "./components/screen-router.js";
import { useGlobalShortcuts, HelpOverlay } from "./hooks/use-global-shortcuts.js";
import { ResponsiveHeader, ResponsiveTable, ResponsiveTaskRow, ResponsiveStatusBar } from "./components/responsive-layout.js";
import { fileURLToPath } from "url";
/**
@@ -64,12 +75,8 @@ function DemoApp() {
</Box>
)}
{/* Header */}
<Box paddingBottom={1}>
<Text bold>Fusion TUI</Text>
<Text> | Project: {projectPath}</Text>
<Text dimColor> (Press ? for help)</Text>
</Box>
{/* Responsive Header */}
<ResponsiveHeader title={`Fusion TUI | Project: ${projectPath}`} />
{/* Screen Router */}
<ScreenRouter
@@ -82,6 +89,24 @@ function DemoApp() {
<Box flexDirection="column" paddingY={1}>
<Text bold>Board Screen</Text>
<Text dimColor>View and manage tasks on the kanban board</Text>
{/* Demo: Responsive Task Table */}
<Box marginTop={1}>
<ResponsiveTable
columns={[
{ header: "ID", minWidth: 10 },
{ header: "Description", minWidth: 30, canGrow: true, preferredWidth: 60 },
{ header: "Status", minWidth: 12 },
{ header: "Size", minWidth: 6 },
]}
rows={[
["FN-001", "Implement user authentication with OAuth 2.0 integration", "todo", "M"],
["FN-002", "Fix memory leak in data processing pipeline caused by missing cleanup handlers", "in-progress", "L"],
["FN-003", "Update documentation", "done", "S"],
["FN-004", "Refactor API endpoints to use REST conventions and add proper error handling with retry logic", "review", "M"],
]}
/>
</Box>
</Box>
)}
{activeScreen === "detail" && (
@@ -111,6 +136,9 @@ function DemoApp() {
</Box>
)}
</ScreenRouter>
{/* Responsive Status Bar */}
<ResponsiveStatusBar />
</Box>
);
}

View File

@@ -0,0 +1,23 @@
/**
* TUI Utility modules.
*/
export {
useTerminalDimensions,
computeColumnLayout,
type TerminalDimensions,
type ColumnLayout,
type ColumnDefinition,
type ColumnStrategy,
MIN_TERMINAL_COLUMNS,
MIN_TERMINAL_ROWS,
} from "./terminal.js";
export {
truncateText,
truncateWithOptions,
padText,
fitText,
DEFAULT_ELLIPSIS,
type TruncateOptions,
} from "./truncate.js";

View File

@@ -0,0 +1,256 @@
/**
* Terminal dimension utilities for responsive TUI layouts.
*
* Provides hooks and helpers for reading live terminal dimensions from Ink's
* useStdout() and computing deterministic column widths.
*/
import { useStdout } from "ink";
import { useMemo } from "react";
/**
* Minimum supported terminal dimensions.
* These values are used as lower bounds for layout calculations.
*/
export const MIN_TERMINAL_COLUMNS = 80;
export const MIN_TERMINAL_ROWS = 24;
/**
* Effective terminal dimensions with minimum bounds applied.
*/
export interface TerminalDimensions {
/** Effective column count (minimum 80) */
columns: number;
/** Effective row count (minimum 24) */
rows: number;
/** Whether the terminal meets minimum size requirements */
isMinimumSize: boolean;
/** Extra columns available beyond the minimum */
extraColumns: number;
}
/**
* useTerminalDimensions - Hook to read live terminal dimensions with minimum bounds.
*
* Uses Ink's useStdout() to get the actual terminal size, then applies minimum
* bounds of 80 columns and 24 rows for layout calculations. This ensures
* deterministic layout even in smaller terminals.
*
* The hook updates whenever the terminal is resized.
*
* @returns {TerminalDimensions} Effective terminal dimensions
*
* @example
* ```tsx
* function MyComponent() {
* const { columns, rows, isMinimumSize, extraColumns } = useTerminalDimensions();
*
* return (
* <Box>
* <Text>Terminal: {columns}x{rows}</Text>
* {!isMinimumSize && <Text dimColor> (narrow)</Text>}
* </Box>
* );
* }
* ```
*/
export function useTerminalDimensions(): TerminalDimensions {
const { stdout } = useStdout();
// Defensive: use default terminal dimensions if stdout is unavailable
const columns = stdout?.columns ?? MIN_TERMINAL_COLUMNS;
const rows = stdout?.rows ?? MIN_TERMINAL_ROWS;
return useMemo(() => {
const effectiveColumns = Math.max(columns, MIN_TERMINAL_COLUMNS);
const effectiveRows = Math.max(rows, MIN_TERMINAL_ROWS);
const extraColumns = Math.max(0, effectiveColumns - MIN_TERMINAL_COLUMNS);
const isMinimumSize = effectiveColumns <= MIN_TERMINAL_COLUMNS && effectiveRows <= MIN_TERMINAL_ROWS;
return {
columns: effectiveColumns,
rows: effectiveRows,
isMinimumSize,
extraColumns,
};
}, [columns, rows]);
}
/**
* Column layout configuration for responsive tables/lists.
*/
export interface ColumnLayout {
/** Width of each column */
widths: number[];
/** Total width used by all columns */
totalWidth: number;
/** Remaining columns after minimum allocations */
remainingColumns: number;
}
/**
* Column allocation strategy.
*/
export type ColumnStrategy = "equal" | "fixed" | "proportional" | "content-heavy";
/**
* Column definition for layout calculation.
*/
export interface ColumnDefinition {
/** Minimum width for this column */
minWidth: number;
/** Preferred/ideal width (optional) */
preferredWidth?: number;
/** Whether this column can grow to fill extra space */
canGrow?: boolean;
/** Growth weight relative to other growable columns */
growWeight?: number;
}
/**
* computeColumnLayout - Calculate column widths based on terminal dimensions.
*
* Produces deterministic column widths that:
* - Respect minimum column widths
* - Keep required columns readable at 80 columns
* - Share extra width with content-heavy columns
*
* @param columns - Available terminal columns
* @param definitions - Column definitions with minimum/preferred widths
* @param strategy - Allocation strategy for extra space
* @returns {ColumnLayout} Calculated column widths
*
* @example
* ```tsx
* const layout = computeColumnLayout(100, [
* { minWidth: 10, canGrow: false }, // ID column
* { minWidth: 40, canGrow: true, growWeight: 2 }, // Description (grows 2x)
* { minWidth: 10, canGrow: true, growWeight: 1 }, // Status (grows 1x)
* ], "proportional");
* // Returns widths array based on available space
* ```
*/
export function computeColumnLayout(
columns: number,
definitions: ColumnDefinition[],
strategy: ColumnStrategy = "proportional"
): ColumnLayout {
const definitionCount = definitions.length;
if (definitionCount === 0) {
return { widths: [], totalWidth: 0, remainingColumns: columns };
}
// Step 1: Calculate minimum total width
const minimumTotal = definitions.reduce((sum, def) => sum + def.minWidth, 0);
// Step 2: If at or below minimum, use minimum widths
if (columns <= minimumTotal) {
return {
widths: definitions.map((def) => def.minWidth),
totalWidth: minimumTotal,
remainingColumns: 0,
};
}
// Step 3: Distribute extra columns based on strategy
const extraColumns = columns - minimumTotal;
const growableColumns = definitions
.map((def, index) => ({ def, index, weight: def.growWeight ?? 1 }))
.filter(({ def }) => def.canGrow);
if (growableColumns.length === 0 || strategy === "fixed") {
// Fixed strategy: don't distribute extra space
return {
widths: definitions.map((def) => def.minWidth),
totalWidth: minimumTotal,
remainingColumns: extraColumns,
};
}
if (strategy === "equal") {
// Equal strategy: divide extra space evenly among growable columns
const extraPerGrowable = Math.floor(extraColumns / growableColumns.length);
const widths = definitions.map((def) => def.minWidth);
for (const { index } of growableColumns) {
widths[index] += extraPerGrowable;
}
return {
widths,
totalWidth: columns,
remainingColumns: extraColumns % growableColumns.length,
};
}
if (strategy === "proportional") {
// Proportional strategy: distribute based on grow weights
const totalWeight = growableColumns.reduce((sum, c) => sum + c.weight, 0);
const widths = definitions.map((def) => def.minWidth);
let distributed = 0;
// Distribute proportionally (all but last to avoid rounding errors)
for (let i = 0; i < growableColumns.length - 1; i++) {
const { index, weight } = growableColumns[i];
const share = Math.floor((extraColumns * weight) / totalWeight);
widths[index] += share;
distributed += share;
}
// Last growable column gets the remainder
const last = growableColumns[growableColumns.length - 1];
widths[last.index] += extraColumns - distributed;
return {
widths,
totalWidth: columns,
remainingColumns: 0,
};
}
// Content-heavy: prioritize columns with preferredWidth
// Distribute based on how much each column is below its preferred width
const widths = definitions.map((def) => def.minWidth);
const contentScores = definitions.map((def) => {
if (!def.canGrow) return 0;
const preferred = def.preferredWidth ?? def.minWidth * 2;
return Math.max(0, preferred - def.minWidth);
});
const totalScore = contentScores.reduce((a, b) => a + b, 0);
if (totalScore === 0) {
// Fall back to equal distribution
const extraPerGrowable = Math.floor(extraColumns / growableColumns.length);
for (const { index } of growableColumns) {
widths[index] += extraPerGrowable;
}
return {
widths,
totalWidth: columns,
remainingColumns: extraColumns % growableColumns.length,
};
}
// Distribute proportionally to content score
let distributed = 0;
const sortedGrowable = [...growableColumns].sort((a, b) => {
const scoreA = contentScores[a.index];
const scoreB = contentScores[b.index];
return scoreB - scoreA; // Higher scores first
});
for (let i = 0; i < sortedGrowable.length - 1; i++) {
const { index } = sortedGrowable[i];
const share = Math.floor((extraColumns * contentScores[index]) / totalScore);
widths[index] += share;
distributed += share;
}
// Last column gets the remainder
const last = sortedGrowable[sortedGrowable.length - 1];
widths[last.index] += extraColumns - distributed;
return {
widths,
totalWidth: columns,
remainingColumns: 0,
};
}

View File

@@ -0,0 +1,211 @@
/**
* Text truncation utilities for clean terminal display.
*
* Provides consistent ellipsis output for overflow text while
* preserving short text unchanged.
*/
export const DEFAULT_ELLIPSIS = "…";
/**
* truncateText - Truncate text to a maximum width with ellipsis.
*
* When text exceeds maxWidth:
* - If maxWidth < 4, text is replaced with just ellipsis
* - Otherwise, text is truncated to (maxWidth - 1) characters + ellipsis
*
* When text fits within maxWidth, it is returned unchanged.
*
* @param text - Text to truncate (already stripped of ANSI codes)
* @param maxWidth - Maximum width in terminal columns
* @param ellipsis - Ellipsis character(s) to use (default: "…")
* @returns {string} Truncated text with ellipsis if needed
*
* @example
* ```typescript
* truncateText("Hello World", 10); // "Hello World" (fits)
* truncateText("Hello World", 8); // "Hello W…"
* truncateText("Hello World", 3); // "…" (too short for meaningful truncation)
* truncateText("Hello World", 2); // "…" (minimum display width)
* ```
*/
export function truncateText(text: string, maxWidth: number, ellipsis: string = DEFAULT_ELLIPSIS): string {
if (maxWidth <= 0) {
return "";
}
const textWidth = text.length;
// Text fits within maxWidth
if (textWidth <= maxWidth) {
return text;
}
// Too short for meaningful truncation
if (maxWidth < 4) {
return ellipsis.slice(0, Math.max(1, maxWidth));
}
// Truncate with ellipsis - reserve space for the actual ellipsis length
const availableWidth = maxWidth - ellipsis.length;
if (availableWidth <= 0) {
// Ellipsis alone exceeds width
return ellipsis.slice(0, Math.max(1, maxWidth));
}
return text.slice(0, availableWidth) + ellipsis;
}
/**
* TruncateOptions - Configuration options for truncate functions.
*/
export interface TruncateOptions {
/** Ellipsis character(s) to use */
ellipsis?: string;
/** Whether to preserve words (avoid breaking mid-word) */
preserveWords?: boolean;
/** Minimum width threshold for truncation */
minTruncateWidth?: number;
}
/**
* truncateWithOptions - Truncate with additional options.
*
* @param text - Text to truncate
* @param maxWidth - Maximum width
* @param options - Truncation options
* @returns {string} Truncated text
*/
export function truncateWithOptions(
text: string,
maxWidth: number,
options: TruncateOptions = {}
): string {
const {
ellipsis = DEFAULT_ELLIPSIS,
preserveWords = false,
minTruncateWidth = 4,
} = options;
if (maxWidth <= 0) {
return "";
}
const textWidth = text.length;
// Text fits within maxWidth
if (textWidth <= maxWidth) {
return text;
}
// Too short for meaningful truncation
if (maxWidth < minTruncateWidth) {
return ellipsis.slice(0, Math.max(1, maxWidth));
}
if (preserveWords) {
// Find the last space before the truncation point
const availableWidth = maxWidth - 1;
const truncatedAt = text.slice(0, availableWidth);
const lastSpace = truncatedAt.lastIndexOf(" ");
if (lastSpace > availableWidth * 0.5) {
// There's a word boundary in the first half - break there
const wordBoundary = text.slice(0, lastSpace).trimEnd();
if (wordBoundary.length + ellipsis.length <= maxWidth) {
return wordBoundary + ellipsis;
}
}
}
// Standard truncation
const availableWidth = maxWidth - ellipsis.length;
return text.slice(0, Math.max(0, availableWidth)) + ellipsis;
}
/**
* padText - Pad text to a specific width.
*
* @param text - Text to pad
* @param width - Target width
* @param align - Alignment direction ("left" | "right" | "center")
* @returns {string} Padded text
*
* @example
* ```typescript
* padText("Hi", 6); // "Hi " (left by default)
* padText("Hi", 6, "right"); // " Hi"
* padText("Hi", 6, "center"); // " Hi "
* ```
*/
export function padText(text: string, width: number, align: "left" | "right" | "center" = "left"): string {
if (width <= 0) {
return "";
}
const textWidth = text.length;
// Text equals or exceeds target width
if (textWidth >= width) {
return text.slice(0, width);
}
const padding = width - textWidth;
switch (align) {
case "right":
return " ".repeat(padding) + text;
case "center": {
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return " ".repeat(leftPad) + text + " ".repeat(rightPad);
}
default:
return text + " ".repeat(padding);
}
}
/**
* fitText - Fit text to a width by truncating or padding.
*
* @param text - Text to fit
* @param width - Target width
* @param align - Alignment when text is shorter than width
* @param ellipsis - Ellipsis for truncation (omit to use padding instead)
* @returns {string} Text fitted to width
*
* @example
* ```typescript
* fitText("Hi", 6); // "Hi " (padded)
* fitText("Hello World", 6); // "Hello " (truncated without ellipsis)
* ```
*/
export function fitText(
text: string,
width: number,
align: "left" | "right" | "center" = "left",
ellipsis?: string
): string {
if (width <= 0) {
return "";
}
const textWidth = text.length;
// Text exceeds target width
if (textWidth > width) {
if (ellipsis) {
return truncateText(text, width, ellipsis);
}
// Without ellipsis, truncate but don't include trailing space from mid-word break
const truncated = text.slice(0, width);
return truncated.trimEnd();
}
// Text fits perfectly - no padding needed
if (textWidth === width) {
return text;
}
// Text is shorter - pad to fit
return padText(text, width, align);
}