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:
362
packages/tui/src/__tests__/responsive-layout.test.tsx
Normal file
362
packages/tui/src/__tests__/responsive-layout.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
205
packages/tui/src/components/responsive-layout.tsx
Normal file
205
packages/tui/src/components/responsive-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
23
packages/tui/src/utils/index.ts
Normal file
23
packages/tui/src/utils/index.ts
Normal 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";
|
||||
256
packages/tui/src/utils/terminal.ts
Normal file
256
packages/tui/src/utils/terminal.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
211
packages/tui/src/utils/truncate.ts
Normal file
211
packages/tui/src/utils/truncate.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user