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

@@ -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>
);
}