From 32a32f4e9d49699b38aab705ad9f4eab0e35bc65 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 27 Jul 2026 21:45:10 +0000 Subject: [PATCH 1/6] fix: make data table columns terminal-aware --- bun.lock | 5 +- package.json | 3 +- src/components/HarnessEndpointPicker.tsx | 24 +++- src/components/HarnessPicker.tsx | 22 ++- src/components/HarnessVersionPicker.tsx | 20 ++- src/components/PaginatedTablePicker.test.tsx | 65 +++++++++ src/components/RuntimeEndpointPicker.tsx | 24 +++- src/components/RuntimePicker.tsx | 30 +++- src/components/RuntimeVersionPicker.tsx | 20 ++- src/components/pickerFormatters.test.ts | 21 +++ src/components/pickerFormatters.ts | 10 ++ .../ui/data-table/DataTable.test.tsx | 71 +++++++++ src/components/ui/data-table/DataTable.tsx | 113 +++++++++------ src/components/ui/data-table/index.ts | 8 ++ .../ui/data-table/useColumnWidths.test.ts | 136 ++++++++++++++++++ .../ui/data-table/useColumnWidths.ts | 109 ++++++++++++++ .../endpoint/list/list.screen.test.tsx | 10 +- .../harness/list/list.screen.test.tsx | 8 +- .../harness/version/list/list.screen.test.tsx | 8 +- .../runtime/endpoint/endpoint.screen.test.tsx | 10 +- src/handlers/runtime/runtime.screen.test.tsx | 16 ++- .../runtime/version/version.screen.test.tsx | 13 +- 22 files changed, 633 insertions(+), 113 deletions(-) create mode 100644 src/components/pickerFormatters.test.ts create mode 100644 src/components/pickerFormatters.ts create mode 100644 src/components/ui/data-table/DataTable.test.tsx create mode 100644 src/components/ui/data-table/useColumnWidths.test.ts create mode 100644 src/components/ui/data-table/useColumnWidths.ts diff --git a/bun.lock b/bun.lock index 14ce1002f..a1dc6b902 100644 --- a/bun.lock +++ b/bun.lock @@ -8,9 +8,9 @@ "@aws-sdk/client-bedrock-agentcore": "^3.1079.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", "@aws-sdk/client-iam": "^3.1080.0", - "@inkui-cli/data-table": "^0.2.0", "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", + "cli-truncate": "^6.1.1", "commander": "^15.0.0", "ink": "^7.1.0", "ink-scroll-view": "^0.3.7", @@ -18,6 +18,7 @@ "react": "^19.2.7", "react-devtools-core": "^7.0.1", "react-router": "^8.3.0", + "string-width": "^8.2.2", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^4.4.3", @@ -79,8 +80,6 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], - "@inkui-cli/data-table": ["@inkui-cli/data-table@0.2.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.0.0" } }, "sha512-kekgwqWnsluB/khs/SO5CGVuMUPJdaPJzGEkJNk3bFnBqFOJNq5S7DpmAtXz/Lta2PqGoiXaSPWewm6PNItznQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw=="], diff --git a/package.json b/package.json index 3f2182a84..baca2ba63 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,9 @@ "@aws-sdk/client-bedrock-agentcore": "^3.1079.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1079.0", "@aws-sdk/client-iam": "^3.1080.0", - "@inkui-cli/data-table": "^0.2.0", "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", + "cli-truncate": "^6.1.1", "commander": "^15.0.0", "ink": "^7.1.0", "ink-scroll-view": "^0.3.7", @@ -62,6 +62,7 @@ "react": "^19.2.7", "react-devtools-core": "^7.0.1", "react-router": "^8.3.0", + "string-width": "^8.2.2", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^4.4.3" diff --git a/src/components/HarnessEndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx index 4ccd78d0d..d9e71b621 100644 --- a/src/components/HarnessEndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -3,6 +3,8 @@ import type { HarnessEndpoint } from "@aws-sdk/client-bedrock-agentcore-control" import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import { formatTimestamp } from "./pickerFormatters"; +import type { DataTableColumn } from "./ui/data-table"; // EndpointRow is the flat, display-ready shape the table renders. interface EndpointRow extends Record { @@ -13,6 +15,20 @@ interface EndpointRow extends Record { updatedAt: string; } +export const harnessEndpointColumns = [ + { key: "endpointName", header: "name", flex: true }, + { key: "liveVersion", header: "live", width: 6, minWidth: 5 }, + { key: "targetVersion", header: "target", width: 6, minWidth: 6 }, + { key: "status", header: "status", width: 13, minWidth: 13 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + minWidth: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + function toRow(e: HarnessEndpoint): EndpointRow { return { endpointName: e.endpointName!, @@ -70,13 +86,7 @@ export function HarnessEndpointPicker({ }; }} toRow={toRow} - columns={[ - { key: "endpointName", header: "name" }, - { key: "liveVersion", header: "live" }, - { key: "targetVersion", header: "target" }, - { key: "status", header: "status" }, - { key: "updatedAt", header: "updatedAt" }, - ]} + columns={harnessEndpointColumns} getValue={(row) => row.endpointName} onSelect={onSelect} onBack={goBack} diff --git a/src/components/HarnessPicker.tsx b/src/components/HarnessPicker.tsx index 3b4d5f3dd..c1ec5cd47 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -3,6 +3,8 @@ import type { HarnessSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import { formatTimestamp } from "./pickerFormatters"; +import type { DataTableColumn } from "./ui/data-table"; // HarnessRow is the flat, display-ready shape the table renders. It also satisfies // DataTable's `T extends Record` constraint, which the SDK's @@ -15,6 +17,19 @@ interface HarnessRow extends Record { status: string; } +export const harnessColumns = [ + { key: "harnessName", header: "name", flex: true }, + { key: "harnessVersion", header: "ver", width: 5, minWidth: 3 }, + { key: "status", header: "status", width: 13, minWidth: 13 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + minWidth: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + // toRow flattens a HarnessSummary into a HarnessRow, formatting dates. function toRow(h: HarnessSummary): HarnessRow { return { @@ -68,12 +83,7 @@ export function HarnessPicker({ }; }} toRow={toRow} - columns={[ - { key: "harnessName", header: "name" }, - { key: "harnessVersion", header: "version" }, - { key: "status", header: "status" }, - { key: "updatedAt", header: "updatedAt" }, - ]} + columns={harnessColumns} getValue={(row) => row.harnessId} onSelect={onSelect} onBack={goBack} diff --git a/src/components/HarnessVersionPicker.tsx b/src/components/HarnessVersionPicker.tsx index f4e6a2a8f..f7db807bd 100644 --- a/src/components/HarnessVersionPicker.tsx +++ b/src/components/HarnessVersionPicker.tsx @@ -3,6 +3,8 @@ import type { HarnessVersionSummary } from "@aws-sdk/client-bedrock-agentcore-co import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import { formatTimestamp } from "./pickerFormatters"; +import type { DataTableColumn } from "./ui/data-table"; // VersionRow is the flat, display-ready shape the table renders. interface VersionRow extends Record { @@ -12,6 +14,18 @@ interface VersionRow extends Record { updatedAt: string; } +export const harnessVersionColumns = [ + { key: "harnessVersion", header: "version", width: 7, minWidth: 4 }, + { key: "status", header: "status", width: 13, minWidth: 6 }, + { + key: "createdAt", + header: "created UTC", + width: 16, + minWidth: 11, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + function toRow(v: HarnessVersionSummary): VersionRow { return { harnessVersion: v.harnessVersion!, @@ -62,11 +76,7 @@ export function HarnessVersionPicker({ }; }} toRow={toRow} - columns={[ - { key: "harnessVersion", header: "version" }, - { key: "status", header: "status" }, - { key: "createdAt", header: "createdAt" }, - ]} + columns={harnessVersionColumns} sortRows={(rows) => [...rows].sort((left, right) => Number(right.harnessVersion) - Number(left.harnessVersion)) } diff --git a/src/components/PaginatedTablePicker.test.tsx b/src/components/PaginatedTablePicker.test.tsx index d14df1fe0..f20fb05b1 100644 --- a/src/components/PaginatedTablePicker.test.tsx +++ b/src/components/PaginatedTablePicker.test.tsx @@ -1,9 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { + AgentRuntime, HarnessSummary, ListHarnessesResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { QueryClient } from "@tanstack/react-query"; +import stringWidth from "string-width"; import { cleanupScreens, renderScreen, TestCoreClient, waitFor, waitForText } from "../testing"; afterEach(cleanupScreens); @@ -27,6 +29,19 @@ function coreWith(harnesses: HarnessSummary[]): TestCoreClient { return core; } +function runtime(overrides: Partial = {}): AgentRuntime { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/orders-AbCdEf1234", + agentRuntimeId: "orders-AbCdEf1234", + agentRuntimeVersion: "99999", + agentRuntimeName: "orders-runtime-with-a-long-name", + description: "Orders Runtime", + lastUpdatedAt: new Date("2026-07-19T01:02:03.000Z"), + status: "CREATE_FAILED", + ...overrides, + }; +} + function getResponse(summary: HarnessSummary) { return { harness: summary } as Parameters[0]; } @@ -287,4 +302,54 @@ describe("paginated table picker contract", () => { ); expect(r.lastFrame()).toContain("agentcore → harness → get → alpha-1"); }); + + test("keeps rows aligned and single-line while resizing the Runtime table", async () => { + const core = new TestCoreClient(); + const suffixes = ["AbCdEf1234", "BcDeFg2345", "CdEfGh3456"]; + core.runtime.setListResponse({ + agentRuntimes: suffixes.map((suffix, index) => + runtime({ + agentRuntimeId: `runtime_${index}-${suffix}`, + agentRuntimeName: `runtime_${index}_with_a_name_that_needs_truncation`, + }), + ), + }); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, suffixes[0]!); + for (const width of [100, 80, 60]) { + if (width !== 100) await r.resize(width); + const lines = (r.lastFrame() ?? "").split("\n"); + const headerIndex = lines.findIndex((line) => line.includes("id suffix")); + const rowLines = suffixes.map((suffix) => lines.find((line) => line.includes(suffix))); + + expect(headerIndex).toBeGreaterThanOrEqual(0); + expect(stringWidth(lines[headerIndex + 1]!)).toBe(width); + expect(rowLines.every((line) => line !== undefined)).toBe(true); + expect(new Set(rowLines.map((line) => lines.indexOf(line!))).size).toBe(suffixes.length); + expect(rowLines.every((line) => stringWidth(line!) <= width)).toBe(true); + expect(rowLines.every((line) => /[A-Za-z0-9]{10}\s+\d/.test(line!))).toBe(true); + } + }); + + test("filters against rendered timestamps and raw identifiers", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ agentRuntimes: [runtime()] }); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "AbCdEf1234"); + await r.write("/"); + await r.write("2026-07-19 01:02"); + await waitForText(r.lastFrame, "/ Filter: 2026-07-19 01:02"); + + expect(r.lastFrame()).toContain("AbCdEf1234"); + expect(r.lastFrame()).not.toContain("No Runtimes found in this Region."); + + await r.press("escape"); + await r.write("/"); + await r.write("orders-AbCdEf1234"); + await waitForText(r.lastFrame, "/ Filter: orders-AbCdEf1234"); + + expect(r.lastFrame()).toContain("AbCdEf1234"); + }); }); diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index 509d5ca57..267bf002c 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -3,6 +3,8 @@ import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import { formatTimestamp } from "./pickerFormatters"; +import type { DataTableColumn } from "./ui/data-table"; interface RuntimeEndpointRow extends Record { qualifier: string; @@ -12,6 +14,20 @@ interface RuntimeEndpointRow extends Record { lastUpdatedAt: string; } +export const runtimeEndpointColumns = [ + { key: "qualifier", header: "qualifier", flex: true }, + { key: "liveVersion", header: "live", width: 6, minWidth: 5 }, + { key: "targetVersion", header: "target", width: 6, minWidth: 6 }, + { key: "status", header: "status", width: 13, minWidth: 13 }, + { + key: "lastUpdatedAt", + header: "updated UTC", + width: 16, + minWidth: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + function toRow(endpoint: AgentRuntimeEndpoint): RuntimeEndpointRow { return { qualifier: endpoint.name ?? endpoint.id ?? "", @@ -54,13 +70,7 @@ export function RuntimeEndpointPicker({ }; }} toRow={toRow} - columns={[ - { key: "qualifier", header: "qualifier" }, - { key: "liveVersion", header: "live" }, - { key: "targetVersion", header: "target" }, - { key: "status", header: "status" }, - { key: "lastUpdatedAt", header: "lastUpdatedAt" }, - ]} + columns={runtimeEndpointColumns} getValue={(row) => row.qualifier} onSelect={onSelect} onBack={goBack} diff --git a/src/components/RuntimePicker.tsx b/src/components/RuntimePicker.tsx index 30cb87301..45190800a 100644 --- a/src/components/RuntimePicker.tsx +++ b/src/components/RuntimePicker.tsx @@ -3,6 +3,8 @@ import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import { formatTimestamp, runtimeIdSuffix } from "./pickerFormatters"; +import type { DataTableColumn } from "./ui/data-table"; interface RuntimeRow extends Record { runtimeId: string; @@ -12,6 +14,26 @@ interface RuntimeRow extends Record { lastUpdatedAt: string; } +export const runtimeColumns = [ + { key: "runtimeName", header: "name", flex: true }, + { + key: "runtimeId", + header: "id suffix", + width: 10, + minWidth: 10, + render: runtimeIdSuffix, + }, + { key: "runtimeVersion", header: "ver", width: 5, minWidth: 3 }, + { key: "status", header: "status", width: 13, minWidth: 13 }, + { + key: "lastUpdatedAt", + header: "updated UTC", + width: 16, + minWidth: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + function toRow(runtime: AgentRuntime): RuntimeRow { const runtimeId = runtime.agentRuntimeId ?? ""; return { @@ -53,13 +75,7 @@ export function RuntimePicker({ }; }} toRow={toRow} - columns={[ - { key: "runtimeName", header: "name" }, - { key: "runtimeId", header: "id" }, - { key: "runtimeVersion", header: "latestVersion" }, - { key: "status", header: "status" }, - { key: "lastUpdatedAt", header: "lastUpdatedAt" }, - ]} + columns={runtimeColumns} getValue={(row) => row.runtimeId} onSelect={onSelect} onBack={goBack} diff --git a/src/components/RuntimeVersionPicker.tsx b/src/components/RuntimeVersionPicker.tsx index ba0ede080..551def1de 100644 --- a/src/components/RuntimeVersionPicker.tsx +++ b/src/components/RuntimeVersionPicker.tsx @@ -3,6 +3,8 @@ import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import { formatTimestamp } from "./pickerFormatters"; +import type { DataTableColumn } from "./ui/data-table"; interface RuntimeVersionRow extends Record { version: string; @@ -10,6 +12,18 @@ interface RuntimeVersionRow extends Record { lastUpdatedAt: string; } +export const runtimeVersionColumns = [ + { key: "version", header: "version", width: 7, minWidth: 4 }, + { key: "status", header: "status", width: 13, minWidth: 6 }, + { + key: "lastUpdatedAt", + header: "updated UTC", + width: 16, + minWidth: 11, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + function toRow(runtime: AgentRuntime): RuntimeVersionRow { return { version: runtime.agentRuntimeVersion ?? "", @@ -50,11 +64,7 @@ export function RuntimeVersionPicker({ }; }} toRow={toRow} - columns={[ - { key: "version", header: "version" }, - { key: "status", header: "status" }, - { key: "lastUpdatedAt", header: "lastUpdatedAt" }, - ]} + columns={runtimeVersionColumns} sortRows={(rows) => [...rows].sort((left, right) => Number(right.version) - Number(left.version)) } diff --git a/src/components/pickerFormatters.test.ts b/src/components/pickerFormatters.test.ts new file mode 100644 index 000000000..e4cc5d5b0 --- /dev/null +++ b/src/components/pickerFormatters.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { formatTimestamp, runtimeIdSuffix } from "./pickerFormatters"; + +describe("picker formatters", () => { + test("formats valid timestamps as compact UTC values", () => { + expect(formatTimestamp("2026-04-22T21:53:27.062Z")).toBe("2026-04-22 21:53"); + expect(formatTimestamp("2026-04-22T14:53:27.062-07:00")).toBe("2026-04-22 21:53"); + }); + + test("renders invalid timestamps as blank values", () => { + for (const value of [undefined, null, "", "garbage"]) { + expect(formatTimestamp(value)).toBe(""); + } + }); + + test("extracts the discriminating Runtime id suffix", () => { + expect(runtimeIdSuffix("orders-AbCdEf1234")).toBe("AbCdEf1234"); + expect(runtimeIdSuffix("legacy-id")).toBe("id"); + expect(runtimeIdSuffix("legacy")).toBe("legacy"); + }); +}); diff --git a/src/components/pickerFormatters.ts b/src/components/pickerFormatters.ts new file mode 100644 index 000000000..261878ee3 --- /dev/null +++ b/src/components/pickerFormatters.ts @@ -0,0 +1,10 @@ +export function formatTimestamp(value: unknown): string { + const date = new Date(String(value ?? "")); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString().slice(0, 16).replace("T", " "); +} + +export function runtimeIdSuffix(value: unknown): string { + const id = String(value ?? ""); + return id.slice(id.lastIndexOf("-") + 1); +} diff --git a/src/components/ui/data-table/DataTable.test.tsx b/src/components/ui/data-table/DataTable.test.tsx new file mode 100644 index 000000000..325029683 --- /dev/null +++ b/src/components/ui/data-table/DataTable.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Box } from "ink"; +import { cleanup, render } from "ink-testing-library"; +import stringWidth from "string-width"; +import { DataTable, type DataTableColumn } from "./DataTable"; + +afterEach(cleanup); + +interface Row extends Record { + name: string; + status: string; +} + +const columns: DataTableColumn[] = [ + { key: "name", header: "name", flex: true }, + { key: "status", header: "status", width: 13, minWidth: 13 }, +]; + +function renderTableAt(columnsWide: number) { + const instance = render(<>); + Object.defineProperty(instance.stdout, "columns", { + configurable: true, + value: columnsWide, + }); + instance.rerender( + + + borderStyle="none" + columns={columns} + data={[ + { name: "first-long-name", status: "CREATE_FAILED" }, + { name: "second-long-name", status: "READY" }, + ]} + searchable={false} + showFooter={false} + /> + , + ); + return instance.lastFrame() ?? ""; +} + +describe("DataTable layout", () => { + test("keeps the selection marker and each logical row on one line at 12 columns", () => { + const lines = renderTableAt(12).split("\n"); + const rowLines = lines.filter( + (line) => line.includes("first") || line.includes("second") || line.includes("❯"), + ); + + expect(rowLines).toHaveLength(2); + expect(rowLines.filter((line) => line.includes("❯"))).toHaveLength(1); + expect(rowLines.every((line) => stringWidth(line) <= 18)).toBe(true); + }); + + test("renders an explicit configuration error for multiple flex columns", () => { + const invalidColumns = [ + { key: "name", header: "name", flex: true }, + { key: "status", header: "status", flex: true }, + ] satisfies DataTableColumn[]; + const instance = render( + + borderStyle="none" + columns={invalidColumns} + data={[]} + searchable={false} + showFooter={false} + />, + ); + + expect(instance.lastFrame()).toContain("DataTable supports at most one flexible column."); + }); +}); diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 9e23cf13f..0d2e6d4c1 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -1,19 +1,32 @@ import React, { useEffect, useState } from "react"; -import { Box, Text, useInput } from "ink"; +import cliTruncate from "cli-truncate"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import stringWidth from "string-width"; import { darkTheme } from "../_core.js"; import type { InkUITheme } from "../_core.js"; +import { + COLUMN_GAP, + computeColumnWidths, + resolveBorderWidth, + SELECTION_MARKER_WIDTH, +} from "./useColumnWidths.js"; -export interface DataTableColumn { +interface DataTableColumnBase { key: keyof T & string; header: string; align?: "left" | "center" | "right"; sortable?: boolean; render?: (value: unknown, row: T) => string; - width?: number; } +type DataTableColumnSizing = + | { flex: true; width?: never; minWidth?: never } + | { flex?: false; width?: number; minWidth?: number }; + +export type DataTableColumn = DataTableColumnBase & DataTableColumnSizing; + export interface DataTableProps { - columns: DataTableColumn[]; + columns: readonly DataTableColumn[]; data: T[]; pageSize?: number; searchable?: boolean; @@ -66,6 +79,7 @@ export function DataTable>({ selectionResetKey, theme = darkTheme, }: DataTableProps): React.ReactElement { + const { columns: terminalWidth } = useWindowSize(); const [selectedRow, setSelectedRow] = useState(0); const [currentPage, setCurrentPage] = useState(0); const [sortColumn, setSortColumn] = useState(null); @@ -82,8 +96,10 @@ export function DataTable>({ const filtered = data.filter((row) => { if (!searchQuery) return true; return columns.some((col) => { - const val = row[col.key]; - return String(val).toLowerCase().includes(searchQuery.toLowerCase()); + const rawValue = String(row[col.key] ?? ""); + const renderedValue = col.render ? col.render(row[col.key], row) : rawValue; + const query = searchQuery.toLowerCase(); + return rawValue.toLowerCase().includes(query) || renderedValue.toLowerCase().includes(query); }); }); @@ -166,25 +182,16 @@ export function DataTable>({ { isActive: focus }, ); - // default the width of each column - const columnsWithWidths = columns.map((col) => { - if (col.width !== undefined) return { ...col, width: col.width }; - const maxData = pageData.reduce((max, row) => { - const val = col.render ? col.render(row[col.key], row) : String(row[col.key] ?? ""); - return Math.max(max, val.length); - }, 0); - return { ...col, width: Math.max(col.header.length + 2, maxData + 2, 6) }; - }); - const pad = (s: string, w: number, align: "left" | "center" | "right" = "left") => { - if (s.length >= w) return s.slice(0, w); - const extra = w - s.length; - if (align === "right") return " ".repeat(extra) + s; + const value = cliTruncate(s, w); + const extra = w - stringWidth(value); + if (align === "right") return " ".repeat(extra) + value; if (align === "center") - return " ".repeat(Math.floor(extra / 2)) + s + " ".repeat(Math.ceil(extra / 2)); - return s + " ".repeat(extra); + return " ".repeat(Math.floor(extra / 2)) + value + " ".repeat(Math.ceil(extra / 2)); + return value + " ".repeat(extra); }; + // Ink does not define a "none" border style and throws if it is passed through. const bord = borderStyle === "none" ? undefined @@ -193,6 +200,19 @@ export function DataTable>({ : borderStyle === "bold" ? "bold" : "single"; + const borderWidth = resolveBorderWidth(bord, borderLeft, borderRight); + const computedWidths = computeColumnWidths(columns, terminalWidth, { + selectable, + borderWidth, + }); + const columnsWithWidths = columns.flatMap((column, index) => { + const width = computedWidths.widths[index]; + return width === undefined ? [] : [{ column, width }]; + }); + + if (columns.filter((column) => column.flex === true).length > 1) { + return DataTable supports at most one flexible column.; + } return ( @@ -224,14 +244,19 @@ export function DataTable>({ borderRight={borderRight} > {/* Header */} - - {selectable && {" "}} - {columnsWithWidths.map((col) => { - const sortArrow = sortColumn === col.key ? (sortDirection === "asc" ? " ▲" : " ▼") : ""; + + {selectable && ( + + + + )} + {columnsWithWidths.map(({ column, width }) => { + const sortArrow = + sortColumn === column.key ? (sortDirection === "asc" ? " ▲" : " ▼") : ""; return ( - - - {pad(col.header + sortArrow, col.width, col.align)} + + + {pad(column.header + sortArrow, width, column.align)} ); @@ -241,11 +266,7 @@ export function DataTable>({ {/* Divider (or a blank spacer line when hidden) */} - {showDivider - ? "─".repeat( - columnsWithWidths.reduce((acc, col) => acc + col.width, 0) + (selectable ? 2 : 0), - ) - : " "} + {showDivider ? "─".repeat(computedWidths.totalWidth) : " "} @@ -258,23 +279,29 @@ export function DataTable>({ pageData.map((row, i) => { const isSelected = i === selectedRow && selectable; return ( - + {selectable && ( - - {isSelected ? "❯ " : " "} - + + + {isSelected ? "❯" : " "} + + )} - {columnsWithWidths.map((col) => { - const val = col.render - ? col.render(row[col.key], row) - : String(row[col.key] ?? ""); + {columnsWithWidths.map(({ column, width }) => { + const val = column.render + ? column.render(row[column.key], row) + : String(row[column.key] ?? ""); return ( - + - {pad(val, col.width, col.align)} + {pad(val, width, column.align)} ); diff --git a/src/components/ui/data-table/index.ts b/src/components/ui/data-table/index.ts index 592e2f416..ba45a6673 100644 --- a/src/components/ui/data-table/index.ts +++ b/src/components/ui/data-table/index.ts @@ -1,2 +1,10 @@ export { DataTable } from "./DataTable.js"; export type { DataTableProps, DataTableColumn } from "./DataTable.js"; +export { + COLUMN_GAP, + COLUMN_MIN_WIDTH, + computeColumnWidths, + FLEX_MIN_WIDTH, + resolveBorderWidth, + SELECTION_MARKER_WIDTH, +} from "./useColumnWidths.js"; diff --git a/src/components/ui/data-table/useColumnWidths.test.ts b/src/components/ui/data-table/useColumnWidths.test.ts new file mode 100644 index 000000000..c6be081d0 --- /dev/null +++ b/src/components/ui/data-table/useColumnWidths.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { harnessEndpointColumns } from "../../HarnessEndpointPicker"; +import { harnessColumns } from "../../HarnessPicker"; +import { harnessVersionColumns } from "../../HarnessVersionPicker"; +import { runtimeEndpointColumns } from "../../RuntimeEndpointPicker"; +import { runtimeColumns } from "../../RuntimePicker"; +import { runtimeVersionColumns } from "../../RuntimeVersionPicker"; +import { + computeColumnWidths, + FLEX_MIN_WIDTH, + resolveBorderWidth, + SELECTION_MARKER_WIDTH, +} from "./useColumnWidths"; + +const widths = [40, 60, 80, 100, 120, 160, 200]; +const flexConfigs = [ + { name: "Runtime", columns: runtimeColumns, flexIndex: 0 }, + { name: "Harness", columns: harnessColumns, flexIndex: 0 }, + { name: "Harness endpoint", columns: harnessEndpointColumns, flexIndex: 0 }, + { name: "Runtime endpoint", columns: runtimeEndpointColumns, flexIndex: 0 }, +] as const; +const fixedConfigs = [ + { name: "Runtime version", columns: runtimeVersionColumns }, + { name: "Harness version", columns: harnessVersionColumns }, +] as const; + +describe("computeColumnWidths", () => { + for (const config of flexConfigs) { + test(`${config.name} fills supported terminal widths without overflow`, () => { + for (const terminalWidth of widths) { + const result = computeColumnWidths(config.columns, terminalWidth, { + selectable: true, + borderWidth: 0, + }); + + expect(result.totalWidth).toBe(terminalWidth); + expect(result.widths[config.flexIndex]!).toBeGreaterThanOrEqual(FLEX_MIN_WIDTH); + } + }); + } + + for (const config of fixedConfigs) { + test(`${config.name} remains content-sized when space is available`, () => { + for (const terminalWidth of widths) { + const result = computeColumnWidths(config.columns, terminalWidth, { + selectable: true, + borderWidth: 0, + }); + + expect(result.totalWidth).toBe(40); + expect(result.totalWidth).toBeLessThanOrEqual(terminalWidth); + expect(result.droppedIndices).toEqual([]); + } + }); + } + + test("shrinks and drops fixed columns from right to left", () => { + expect(computeColumnWidths(runtimeColumns, 40, { selectable: true, borderWidth: 0 })).toEqual({ + widths: [23, 10, 3, undefined, undefined], + droppedIndices: [3, 4], + totalWidth: 40, + }); + expect(computeColumnWidths(runtimeColumns, 60, { selectable: true, borderWidth: 0 })).toEqual({ + widths: [29, 10, 3, 13, undefined], + droppedIndices: [4], + totalWidth: 60, + }); + expect(computeColumnWidths(runtimeColumns, 80, { selectable: true, borderWidth: 0 })).toEqual({ + widths: [30, 10, 5, 13, 16], + droppedIndices: [], + totalWidth: 80, + }); + }); + + test("preserves all legal version digits at normal terminal widths", () => { + const runtime = computeColumnWidths(runtimeColumns, 80, { + selectable: true, + borderWidth: 0, + }); + const harness = computeColumnWidths(harnessColumns, 80, { + selectable: true, + borderWidth: 0, + }); + const endpoint = computeColumnWidths(runtimeEndpointColumns, 80, { + selectable: true, + borderWidth: 0, + }); + + expect(runtime.widths[2]).toBe(5); + expect(harness.widths[1]).toBe(5); + expect(endpoint.widths[1]).toBe(6); + expect(endpoint.widths[2]).toBe(6); + }); + + test("terminates and respects the documented narrow-terminal bound", () => { + for (const config of [...flexConfigs, ...fixedConfigs]) { + const hasFlex = config.columns.some((column) => "flex" in column && column.flex === true); + for (let terminalWidth = 1; terminalWidth <= 20; terminalWidth += 1) { + const result = computeColumnWidths(config.columns, terminalWidth, { + selectable: true, + borderWidth: 0, + }); + const bound = hasFlex + ? Math.max(terminalWidth, SELECTION_MARKER_WIDTH + 1 + FLEX_MIN_WIDTH) + : terminalWidth; + + expect(result.totalWidth).toBeLessThanOrEqual(bound); + } + } + }); + + test("does not reserve a phantom gap after every data column drops", () => { + const result = computeColumnWidths(runtimeVersionColumns, 1, { + selectable: true, + borderWidth: 0, + }); + + expect(result.widths).toEqual([undefined, undefined, undefined]); + expect(result.droppedIndices).toEqual([0, 1, 2]); + expect(result.totalWidth).toBe(SELECTION_MARKER_WIDTH); + }); + + test("accounts for resolved borders", () => { + const borderWidth = resolveBorderWidth("single", undefined, undefined); + const result = computeColumnWidths(runtimeColumns, 80, { + selectable: true, + borderWidth, + }); + + expect(borderWidth).toBe(2); + expect(result.totalWidth + borderWidth).toBe(80); + expect(resolveBorderWidth("single", false, undefined)).toBe(1); + expect(resolveBorderWidth("single", false, false)).toBe(0); + expect(resolveBorderWidth(undefined, undefined, undefined)).toBe(0); + }); +}); diff --git a/src/components/ui/data-table/useColumnWidths.ts b/src/components/ui/data-table/useColumnWidths.ts new file mode 100644 index 000000000..54d27f70f --- /dev/null +++ b/src/components/ui/data-table/useColumnWidths.ts @@ -0,0 +1,109 @@ +import stringWidth from "string-width"; + +export const COLUMN_GAP = 1; +export const COLUMN_MIN_WIDTH = 4; +export const FLEX_MIN_WIDTH = 16; +export const SELECTION_MARKER_WIDTH = 1; + +export interface ColumnSizing { + header: string; + sortable?: boolean; + flex?: boolean; + width?: number; + minWidth?: number; +} + +export interface ComputedColumnWidths { + widths: (number | undefined)[]; + droppedIndices: number[]; + totalWidth: number; +} + +interface FixedColumnWidth { + index: number; + width: number; + minWidth: number; +} + +export function resolveBorderWidth( + borderStyle: string | undefined, + borderLeft: boolean | undefined, + borderRight: boolean | undefined, +): number { + if (borderStyle === undefined) return 0; + return Number(borderLeft !== false) + Number(borderRight !== false); +} + +export function computeColumnWidths( + columns: readonly ColumnSizing[], + terminalWidth: number, + options: { selectable: boolean; borderWidth: number }, +): ComputedColumnWidths { + const flexIndex = columns.findIndex((column) => column.flex === true); + const frameWidth = Math.max(0, terminalWidth - options.borderWidth); + const markerWidth = options.selectable ? SELECTION_MARKER_WIDTH : 0; + const flexFloor = flexIndex === -1 ? 0 : FLEX_MIN_WIDTH; + const fixedColumns: FixedColumnWidth[] = columns.flatMap((column, index) => { + if (index === flexIndex) return []; + + const naturalWidth = Math.max( + stringWidth(column.header) + (column.sortable ? 2 : 0), + COLUMN_MIN_WIDTH, + ); + const width = column.width ?? naturalWidth; + return [ + { + index, + width, + minWidth: Math.min(column.minWidth ?? COLUMN_MIN_WIDTH, width), + }, + ]; + }); + + const gapCount = () => { + const visibleColumns = fixedColumns.length + (flexIndex === -1 ? 0 : 1); + const childCount = visibleColumns + (options.selectable ? 1 : 0); + return Math.max(0, childCount - 1); + }; + const fixedWidth = () => fixedColumns.reduce((total, column) => total + column.width, 0); + const totalAtFloor = () => markerWidth + fixedWidth() + flexFloor + gapCount() * COLUMN_GAP; + + while ( + totalAtFloor() > frameWidth && + fixedColumns.some((column) => column.width > column.minWidth) + ) { + for (let index = fixedColumns.length - 1; index >= 0; index -= 1) { + const column = fixedColumns[index]!; + if (column.width > column.minWidth) { + column.width -= 1; + break; + } + } + } + + const droppedIndices: number[] = []; + while (totalAtFloor() > frameWidth && fixedColumns.length > 0) { + droppedIndices.push(fixedColumns.pop()!.index); + } + + const widths: (number | undefined)[] = Array.from({ length: columns.length }); + for (const column of fixedColumns) widths[column.index] = column.width; + + if (flexIndex !== -1) { + widths[flexIndex] = Math.max( + FLEX_MIN_WIDTH, + frameWidth - markerWidth - fixedWidth() - gapCount() * COLUMN_GAP, + ); + } + + const totalWidth = + markerWidth + + widths.reduce((total, width) => total + (width ?? 0), 0) + + gapCount() * COLUMN_GAP; + + return { + widths, + droppedIndices: droppedIndices.sort((left, right) => left - right), + totalWidth, + }; +} diff --git a/src/handlers/harness/endpoint/list/list.screen.test.tsx b/src/handlers/harness/endpoint/list/list.screen.test.tsx index 6080d6b46..88059889b 100644 --- a/src/handlers/harness/endpoint/list/list.screen.test.tsx +++ b/src/handlers/harness/endpoint/list/list.screen.test.tsx @@ -89,8 +89,8 @@ describe("harness endpoint list screen", () => { const core = coreWithEndpoints([ endpoint({ endpointName: "visible-endpoint", - liveVersion: "7", - targetVersion: "88", + liveVersion: "99999", + targetVersion: "88888", status: "UPDATE_FAILED", updatedAt: new Date("2026-07-18T02:00:00.000Z"), }), @@ -103,9 +103,9 @@ describe("harness endpoint list screen", () => { expect(frame).toContain("live"); expect(frame).toContain("target"); expect(frame).toContain("status"); - expect(frame).toContain("updatedAt"); - expect(frame).toMatch(/visible-endpoint\s+7\s+88\s+UPDATE_FAILED/); - expect(frame).toContain("2026-07-18T02:00:00.000Z"); + expect(frame).toContain("updated UTC"); + expect(frame).toMatch(/visible-endpoint\s+99999\s+88888\s+UPDATE_FAILED/); + expect(frame).toContain("2026-07-18 02:00"); r.unmount(); }); diff --git a/src/handlers/harness/list/list.screen.test.tsx b/src/handlers/harness/list/list.screen.test.tsx index 42f4da28f..5f9605e92 100644 --- a/src/handlers/harness/list/list.screen.test.tsx +++ b/src/handlers/harness/list/list.screen.test.tsx @@ -36,7 +36,7 @@ function coreWith(harnesses: HarnessSummary[]): TestCoreClient { describe("harness list screen", () => { test("renders each harness with its columns", async () => { const core = coreWith([ - harness({ harnessName: "alpha", harnessId: "alpha-1" }), + harness({ harnessName: "alpha", harnessId: "alpha-1", harnessVersion: "99999" }), harness({ harnessName: "beta", harnessId: "beta-2" }), ]); const r = renderScreen("/agentcore/harness/list", { core }); @@ -46,9 +46,11 @@ describe("harness list screen", () => { expect(frame).toContain("beta"); expect(frame).toContain("READY"); expect(frame).toContain("name"); - expect(frame).toContain("version"); + expect(frame).toContain("ver"); + expect(frame).toContain("99999"); expect(frame).toContain("status"); - expect(frame).toContain("updatedAt"); + expect(frame).toContain("updated UTC"); + expect(frame).toContain("2026-04-22 21:53"); }); test("makes one initial list request with context options", async () => { diff --git a/src/handlers/harness/version/list/list.screen.test.tsx b/src/handlers/harness/version/list/list.screen.test.tsx index 67f8e5b8b..bbe21dfb7 100644 --- a/src/handlers/harness/version/list/list.screen.test.tsx +++ b/src/handlers/harness/version/list/list.screen.test.tsx @@ -104,7 +104,7 @@ describe("harness version list screen", () => { test("renders version columns and values", async () => { const core = coreWithVersions([ version({ - harnessVersion: "123", + harnessVersion: "99999", status: "UPDATE_FAILED", createdAt: new Date("2026-07-18T02:00:00.000Z"), }), @@ -115,10 +115,10 @@ describe("harness version list screen", () => { const frame = r.lastFrame()!; expect(frame).toContain("version"); expect(frame).toContain("status"); - expect(frame).toContain("createdAt"); - expect(frame).toContain("123"); + expect(frame).toContain("created UTC"); + expect(frame).toContain("99999"); expect(frame).toContain("UPDATE_FAILED"); - expect(frame).toContain("2026-07-18T02:00:00.000Z"); + expect(frame).toContain("2026-07-18 02:00"); r.unmount(); }); diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index aba6958a7..2cdc167ce 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -133,8 +133,8 @@ describe("Runtime endpoint flow", () => { runtimeEndpoints: [ endpoint({ name: "production", - liveVersion: "7", - targetVersion: "8", + liveVersion: "99999", + targetVersion: "88888", status: "UPDATE_FAILED", lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), }), @@ -148,11 +148,11 @@ describe("Runtime endpoint flow", () => { expect(frame).toContain("live"); expect(frame).toContain("target"); expect(frame).toContain("status"); - expect(frame).toContain("lastUpdatedAt"); + expect(frame).toContain("updated UTC"); expect(frame).toMatch(/target\s+status/); - expect(frame).toMatch(/production\s+7\s+8\s+UPDATE_FAILED/); + expect(frame).toMatch(/production\s+99999\s+88888\s+UPDATE_FAILED/); expect(frame).toContain("UPDATE_FAILED"); - expect(frame).toContain("2026-07-18T02:00:00.000Z"); + expect(frame).toContain("2026-07-18 02:00"); expect(frame).not.toContain("protocol"); }); diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 473e46729..3e0e26902 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -73,9 +73,9 @@ describe("runtime picker", () => { test("renders Runtime identity, latest version, status, and update time", async () => { const core = coreWithRuntimes([ runtime({ - agentRuntimeId: "runtime-visible-id", + agentRuntimeId: "orders-AbCdEf1234", agentRuntimeName: "orders", - agentRuntimeVersion: "42", + agentRuntimeVersion: "99999", status: "CREATE_FAILED", lastUpdatedAt: new Date("2026-07-19T01:02:03.000Z"), }), @@ -86,13 +86,15 @@ describe("runtime picker", () => { const frame = r.lastFrame()!; expect(frame).toContain("name"); expect(frame).toContain("id"); - expect(frame).toContain("latestVersion"); + expect(frame).toContain("id suffix"); + expect(frame).toContain("ver"); expect(frame).toContain("status"); - expect(frame).toContain("lastUpdatedAt"); - expect(frame).toContain("runtime-visible-id"); - expect(frame).toContain("42"); + expect(frame).toContain("updated UTC"); + expect(frame).toContain("AbCdEf1234"); + expect(frame).not.toContain("orders-AbCdEf1234"); + expect(frame).toContain("99999"); expect(frame).toContain("CREATE_FAILED"); - expect(frame).toContain("2026-07-19T01:02:03.000Z"); + expect(frame).toContain("2026-07-19 01:02"); }); test("calls listRuntimes once with exact Core options", async () => { diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index c0e2ebcb0..1d19bd748 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -127,7 +127,7 @@ describe("Runtime version flow", () => { runtime({ agentRuntimeId: "hidden-id", agentRuntimeName: "hidden-name", - agentRuntimeVersion: "10", + agentRuntimeVersion: "99999", status: "READY", lastUpdatedAt: new Date("2026-07-20T10:00:00.000Z"), }), @@ -139,15 +139,18 @@ describe("Runtime version flow", () => { const frame = r.lastFrame()!; expect(frame).toContain("version"); expect(frame).toContain("status"); - expect(frame).toContain("lastUpdatedAt"); + expect(frame).toContain("updated UTC"); + expect(frame).toContain("2026-07-20 10:00"); const lines = frame.split("\n"); - const versionTen = lines.findIndex((line) => line.includes("10") && line.includes("READY")); + const newestVersion = lines.findIndex( + (line) => line.includes("99999") && line.includes("READY"), + ); const versionTwo = lines.findIndex( (line) => line.includes("2") && line.includes("UPDATE_FAILED"), ); - expect(versionTen).toBeGreaterThanOrEqual(0); + expect(newestVersion).toBeGreaterThanOrEqual(0); expect(versionTwo).toBeGreaterThanOrEqual(0); - expect(versionTen).toBeLessThan(versionTwo); + expect(newestVersion).toBeLessThan(versionTwo); expect(frame).not.toContain("hidden-id"); expect(frame).not.toContain("hidden-name"); }); From 21ce7090dddc4d4fedd115636ac1b24e29b00230 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 28 Jul 2026 14:24:50 +0000 Subject: [PATCH 2/6] fix: preserve data table sort indicators --- .../ui/data-table/DataTable.test.tsx | 32 +++++++++++++++++++ src/components/ui/data-table/DataTable.tsx | 13 ++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/components/ui/data-table/DataTable.test.tsx b/src/components/ui/data-table/DataTable.test.tsx index 325029683..4520e42c5 100644 --- a/src/components/ui/data-table/DataTable.test.tsx +++ b/src/components/ui/data-table/DataTable.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { Box } from "ink"; import { cleanup, render } from "ink-testing-library"; import stringWidth from "string-width"; +import { waitFor } from "../../../testing"; import { DataTable, type DataTableColumn } from "./DataTable"; afterEach(cleanup); @@ -68,4 +69,35 @@ describe("DataTable layout", () => { expect(instance.lastFrame()).toContain("DataTable supports at most one flexible column."); }); + + test("preserves the sort indicator in an explicit-width column", async () => { + const sortableColumns = [ + { key: "name", header: "name", width: 4, minWidth: 4, sortable: true }, + ] satisfies DataTableColumn[]; + const instance = render( + + borderStyle="none" + columns={sortableColumns} + data={[ + { name: "beta", status: "" }, + { name: "alpha", status: "" }, + ]} + searchable={false} + selectable={false} + showFooter={false} + />, + ); + + instance.stdin.write("s"); + await waitFor(() => instance.lastFrame()?.includes("▲") ?? false); + const ascendingFrame = instance.lastFrame()!; + expect(ascendingFrame.split("\n")[0]).toBe("n… ▲"); + expect(ascendingFrame.indexOf("alp…")).toBeLessThan(ascendingFrame.indexOf("beta")); + + instance.stdin.write("s"); + await waitFor(() => instance.lastFrame()?.includes("▼") ?? false); + const descendingFrame = instance.lastFrame()!; + expect(descendingFrame.split("\n")[0]).toBe("n… ▼"); + expect(descendingFrame.indexOf("beta")).toBeLessThan(descendingFrame.indexOf("alp…")); + }); }); diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 0d2e6d4c1..64024733d 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -57,6 +57,12 @@ export interface DataTableProps { theme?: InkUITheme; } +function headerWithSortIndicator(header: string, width: number, indicator: string): string { + if (!indicator) return header; + const suffix = `${width > stringWidth(indicator) ? " " : ""}${indicator}`; + return cliTruncate(header, Math.max(0, width - stringWidth(suffix))) + suffix; +} + export function DataTable>({ columns, data, @@ -251,12 +257,13 @@ export function DataTable>({ )} {columnsWithWidths.map(({ column, width }) => { - const sortArrow = - sortColumn === column.key ? (sortDirection === "asc" ? " ▲" : " ▼") : ""; + const sortIndicator = + sortColumn === column.key ? (sortDirection === "asc" ? "▲" : "▼") : ""; + const header = headerWithSortIndicator(column.header, width, sortIndicator); return ( - {pad(column.header + sortArrow, width, column.align)} + {pad(header, width, column.align)} ); From 681f93897abba9aedd2b16b07f5f2c237c1a66a8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 28 Jul 2026 14:39:46 +0000 Subject: [PATCH 3/6] refactor: clarify picker formatter ownership --- src/components/HarnessEndpointPicker.tsx | 2 +- src/components/HarnessPicker.tsx | 2 +- src/components/HarnessVersionPicker.tsx | 2 +- src/components/RuntimeEndpointPicker.tsx | 2 +- src/components/RuntimePicker.tsx | 7 ++++++- src/components/RuntimeVersionPicker.tsx | 2 +- ...ickerFormatters.test.ts => formatTimestamp.test.ts} | 10 ++-------- .../{pickerFormatters.ts => formatTimestamp.ts} | 5 ----- 8 files changed, 13 insertions(+), 19 deletions(-) rename src/components/{pickerFormatters.test.ts => formatTimestamp.test.ts} (57%) rename src/components/{pickerFormatters.ts => formatTimestamp.ts} (60%) diff --git a/src/components/HarnessEndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx index d9e71b621..4a07a685a 100644 --- a/src/components/HarnessEndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -2,8 +2,8 @@ import { useNavigate } from "react-router"; import type { HarnessEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; -import { formatTimestamp } from "./pickerFormatters"; import type { DataTableColumn } from "./ui/data-table"; // EndpointRow is the flat, display-ready shape the table renders. diff --git a/src/components/HarnessPicker.tsx b/src/components/HarnessPicker.tsx index c1ec5cd47..efdda49e6 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -2,8 +2,8 @@ import { useNavigate } from "react-router"; import type { HarnessSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; -import { formatTimestamp } from "./pickerFormatters"; import type { DataTableColumn } from "./ui/data-table"; // HarnessRow is the flat, display-ready shape the table renders. It also satisfies diff --git a/src/components/HarnessVersionPicker.tsx b/src/components/HarnessVersionPicker.tsx index f7db807bd..01e87e6a2 100644 --- a/src/components/HarnessVersionPicker.tsx +++ b/src/components/HarnessVersionPicker.tsx @@ -2,8 +2,8 @@ import { useNavigate } from "react-router"; import type { HarnessVersionSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; -import { formatTimestamp } from "./pickerFormatters"; import type { DataTableColumn } from "./ui/data-table"; // VersionRow is the flat, display-ready shape the table renders. diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index 267bf002c..692c8d767 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -2,8 +2,8 @@ import type { AgentRuntimeEndpoint } from "@aws-sdk/client-bedrock-agentcore-con import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; -import { formatTimestamp } from "./pickerFormatters"; import type { DataTableColumn } from "./ui/data-table"; interface RuntimeEndpointRow extends Record { diff --git a/src/components/RuntimePicker.tsx b/src/components/RuntimePicker.tsx index 45190800a..874fc2b2b 100644 --- a/src/components/RuntimePicker.tsx +++ b/src/components/RuntimePicker.tsx @@ -2,8 +2,8 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; -import { formatTimestamp, runtimeIdSuffix } from "./pickerFormatters"; import type { DataTableColumn } from "./ui/data-table"; interface RuntimeRow extends Record { @@ -14,6 +14,11 @@ interface RuntimeRow extends Record { lastUpdatedAt: string; } +function runtimeIdSuffix(value: unknown): string { + const id = String(value ?? ""); + return id.slice(id.lastIndexOf("-") + 1); +} + export const runtimeColumns = [ { key: "runtimeName", header: "name", flex: true }, { diff --git a/src/components/RuntimeVersionPicker.tsx b/src/components/RuntimeVersionPicker.tsx index 551def1de..45233d997 100644 --- a/src/components/RuntimeVersionPicker.tsx +++ b/src/components/RuntimeVersionPicker.tsx @@ -2,8 +2,8 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; import { PaginatedTablePicker } from "./PaginatedTablePicker"; -import { formatTimestamp } from "./pickerFormatters"; import type { DataTableColumn } from "./ui/data-table"; interface RuntimeVersionRow extends Record { diff --git a/src/components/pickerFormatters.test.ts b/src/components/formatTimestamp.test.ts similarity index 57% rename from src/components/pickerFormatters.test.ts rename to src/components/formatTimestamp.test.ts index e4cc5d5b0..1b52ac3f5 100644 --- a/src/components/pickerFormatters.test.ts +++ b/src/components/formatTimestamp.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { formatTimestamp, runtimeIdSuffix } from "./pickerFormatters"; +import { formatTimestamp } from "./formatTimestamp"; -describe("picker formatters", () => { +describe("formatTimestamp", () => { test("formats valid timestamps as compact UTC values", () => { expect(formatTimestamp("2026-04-22T21:53:27.062Z")).toBe("2026-04-22 21:53"); expect(formatTimestamp("2026-04-22T14:53:27.062-07:00")).toBe("2026-04-22 21:53"); @@ -12,10 +12,4 @@ describe("picker formatters", () => { expect(formatTimestamp(value)).toBe(""); } }); - - test("extracts the discriminating Runtime id suffix", () => { - expect(runtimeIdSuffix("orders-AbCdEf1234")).toBe("AbCdEf1234"); - expect(runtimeIdSuffix("legacy-id")).toBe("id"); - expect(runtimeIdSuffix("legacy")).toBe("legacy"); - }); }); diff --git a/src/components/pickerFormatters.ts b/src/components/formatTimestamp.ts similarity index 60% rename from src/components/pickerFormatters.ts rename to src/components/formatTimestamp.ts index 261878ee3..c46881640 100644 --- a/src/components/pickerFormatters.ts +++ b/src/components/formatTimestamp.ts @@ -3,8 +3,3 @@ export function formatTimestamp(value: unknown): string { if (Number.isNaN(date.getTime())) return ""; return date.toISOString().slice(0, 16).replace("T", " "); } - -export function runtimeIdSuffix(value: unknown): string { - const id = String(value ?? ""); - return id.slice(id.lastIndexOf("-") + 1); -} From 9d9b9e2e6440d8e457bf66e6393b10a2b88ef7fc Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 28 Jul 2026 18:46:18 +0000 Subject: [PATCH 4/6] refactor: remove unused data table sorting --- .../ui/data-table/DataTable.test.tsx | 32 ----------- src/components/ui/data-table/DataTable.tsx | 54 ++++--------------- .../ui/data-table/useColumnWidths.ts | 6 +-- 3 files changed, 12 insertions(+), 80 deletions(-) diff --git a/src/components/ui/data-table/DataTable.test.tsx b/src/components/ui/data-table/DataTable.test.tsx index 4520e42c5..325029683 100644 --- a/src/components/ui/data-table/DataTable.test.tsx +++ b/src/components/ui/data-table/DataTable.test.tsx @@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { Box } from "ink"; import { cleanup, render } from "ink-testing-library"; import stringWidth from "string-width"; -import { waitFor } from "../../../testing"; import { DataTable, type DataTableColumn } from "./DataTable"; afterEach(cleanup); @@ -69,35 +68,4 @@ describe("DataTable layout", () => { expect(instance.lastFrame()).toContain("DataTable supports at most one flexible column."); }); - - test("preserves the sort indicator in an explicit-width column", async () => { - const sortableColumns = [ - { key: "name", header: "name", width: 4, minWidth: 4, sortable: true }, - ] satisfies DataTableColumn[]; - const instance = render( - - borderStyle="none" - columns={sortableColumns} - data={[ - { name: "beta", status: "" }, - { name: "alpha", status: "" }, - ]} - searchable={false} - selectable={false} - showFooter={false} - />, - ); - - instance.stdin.write("s"); - await waitFor(() => instance.lastFrame()?.includes("▲") ?? false); - const ascendingFrame = instance.lastFrame()!; - expect(ascendingFrame.split("\n")[0]).toBe("n… ▲"); - expect(ascendingFrame.indexOf("alp…")).toBeLessThan(ascendingFrame.indexOf("beta")); - - instance.stdin.write("s"); - await waitFor(() => instance.lastFrame()?.includes("▼") ?? false); - const descendingFrame = instance.lastFrame()!; - expect(descendingFrame.split("\n")[0]).toBe("n… ▼"); - expect(descendingFrame.indexOf("beta")).toBeLessThan(descendingFrame.indexOf("alp…")); - }); }); diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 64024733d..d1800458d 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -15,7 +15,6 @@ interface DataTableColumnBase { key: keyof T & string; header: string; align?: "left" | "center" | "right"; - sortable?: boolean; render?: (value: unknown, row: T) => string; } @@ -57,12 +56,6 @@ export interface DataTableProps { theme?: InkUITheme; } -function headerWithSortIndicator(header: string, width: number, indicator: string): string { - if (!indicator) return header; - const suffix = `${width > stringWidth(indicator) ? " " : ""}${indicator}`; - return cliTruncate(header, Math.max(0, width - stringWidth(suffix))) + suffix; -} - export function DataTable>({ columns, data, @@ -88,8 +81,6 @@ export function DataTable>({ const { columns: terminalWidth } = useWindowSize(); const [selectedRow, setSelectedRow] = useState(0); const [currentPage, setCurrentPage] = useState(0); - const [sortColumn, setSortColumn] = useState(null); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const [searchQuery, setSearchQuery] = useState(""); const [searchMode, setSearchMode] = useState(false); @@ -109,17 +100,8 @@ export function DataTable>({ }); }); - // Sort - const sorted = sortColumn - ? [...filtered].sort((a, b) => { - const av = String(a[sortColumn] ?? ""); - const bv = String(b[sortColumn] ?? ""); - return sortDirection === "asc" ? av.localeCompare(bv) : bv.localeCompare(av); - }) - : filtered; - - const totalPages = Math.ceil(sorted.length / pageSize); - const pageData = sorted.slice(currentPage * pageSize, (currentPage + 1) * pageSize); + const totalPages = Math.ceil(filtered.length / pageSize); + const pageData = filtered.slice(currentPage * pageSize, (currentPage + 1) * pageSize); useInput( (input, key) => { @@ -170,15 +152,6 @@ export function DataTable>({ setSelectedRow(0); setCurrentPage(0); setSearchMode(true); - } else if (input === "s") { - const col = columns.find((c) => c.sortable); - if (col) { - if (sortColumn === col.key) setSortDirection((d) => (d === "asc" ? "desc" : "asc")); - else { - setSortColumn(col.key); - setSortDirection("asc"); - } - } } else if (key.return) { const row = pageData[selectedRow]; if (row) onSelect?.(row, currentPage * pageSize + selectedRow); @@ -256,18 +229,13 @@ export function DataTable>({ )} - {columnsWithWidths.map(({ column, width }) => { - const sortIndicator = - sortColumn === column.key ? (sortDirection === "asc" ? "▲" : "▼") : ""; - const header = headerWithSortIndicator(column.header, width, sortIndicator); - return ( - - - {pad(header, width, column.align)} - - - ); - })} + {columnsWithWidths.map(({ column, width }) => ( + + + {pad(column.header, width, column.align)} + + + ))} {/* Divider (or a blank spacer line when hidden) */} @@ -324,9 +292,9 @@ export function DataTable>({ Showing {currentPage * pageSize + 1}- - {Math.min((currentPage + 1) * pageSize, sorted.length)} of {sorted.length} · Page{" "} + {Math.min((currentPage + 1) * pageSize, filtered.length)} of {filtered.length} · Page{" "} {currentPage + 1}/{totalPages || 1} - {" · "}[↑↓/jk] Row [←→/hl] Page [/] Search [s] Sort + {" · "}[↑↓/jk] Row [←→/hl] Page [/] Search )} diff --git a/src/components/ui/data-table/useColumnWidths.ts b/src/components/ui/data-table/useColumnWidths.ts index 54d27f70f..bea81ad19 100644 --- a/src/components/ui/data-table/useColumnWidths.ts +++ b/src/components/ui/data-table/useColumnWidths.ts @@ -7,7 +7,6 @@ export const SELECTION_MARKER_WIDTH = 1; export interface ColumnSizing { header: string; - sortable?: boolean; flex?: boolean; width?: number; minWidth?: number; @@ -46,10 +45,7 @@ export function computeColumnWidths( const fixedColumns: FixedColumnWidth[] = columns.flatMap((column, index) => { if (index === flexIndex) return []; - const naturalWidth = Math.max( - stringWidth(column.header) + (column.sortable ? 2 : 0), - COLUMN_MIN_WIDTH, - ); + const naturalWidth = Math.max(stringWidth(column.header), COLUMN_MIN_WIDTH); const width = column.width ?? naturalWidth; return [ { From 7a710cf2c3e040699d08f937232ae49f1de4ea00 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 28 Jul 2026 19:02:52 +0000 Subject: [PATCH 5/6] refactor: simplify data table width API --- src/components/ui/data-table/DataTable.tsx | 17 ++++++------- ...umnWidths.test.ts => columnWidths.test.ts} | 8 ++---- .../{useColumnWidths.ts => columnWidths.ts} | 25 ++++++------------- src/components/ui/data-table/index.ts | 8 ------ 4 files changed, 16 insertions(+), 42 deletions(-) rename src/components/ui/data-table/{useColumnWidths.test.ts => columnWidths.test.ts} (95%) rename src/components/ui/data-table/{useColumnWidths.ts => columnWidths.ts} (79%) diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index d1800458d..b60c99cb2 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -9,7 +9,8 @@ import { computeColumnWidths, resolveBorderWidth, SELECTION_MARKER_WIDTH, -} from "./useColumnWidths.js"; +} from "./columnWidths.js"; +import type { ColumnSizing } from "./columnWidths.js"; interface DataTableColumnBase { key: keyof T & string; @@ -18,11 +19,7 @@ interface DataTableColumnBase { render?: (value: unknown, row: T) => string; } -type DataTableColumnSizing = - | { flex: true; width?: never; minWidth?: never } - | { flex?: false; width?: number; minWidth?: number }; - -export type DataTableColumn = DataTableColumnBase & DataTableColumnSizing; +export type DataTableColumn = DataTableColumnBase & ColumnSizing; export interface DataTableProps { columns: readonly DataTableColumn[]; @@ -170,6 +167,10 @@ export function DataTable>({ return value + " ".repeat(extra); }; + if (columns.filter((column) => column.flex === true).length > 1) { + return DataTable supports at most one flexible column.; + } + // Ink does not define a "none" border style and throws if it is passed through. const bord = borderStyle === "none" @@ -189,10 +190,6 @@ export function DataTable>({ return width === undefined ? [] : [{ column, width }]; }); - if (columns.filter((column) => column.flex === true).length > 1) { - return DataTable supports at most one flexible column.; - } - return ( {/* Search bar */} diff --git a/src/components/ui/data-table/useColumnWidths.test.ts b/src/components/ui/data-table/columnWidths.test.ts similarity index 95% rename from src/components/ui/data-table/useColumnWidths.test.ts rename to src/components/ui/data-table/columnWidths.test.ts index c6be081d0..594cf0a7f 100644 --- a/src/components/ui/data-table/useColumnWidths.test.ts +++ b/src/components/ui/data-table/columnWidths.test.ts @@ -10,7 +10,7 @@ import { FLEX_MIN_WIDTH, resolveBorderWidth, SELECTION_MARKER_WIDTH, -} from "./useColumnWidths"; +} from "./columnWidths"; const widths = [40, 60, 80, 100, 120, 160, 200]; const flexConfigs = [ @@ -49,7 +49,7 @@ describe("computeColumnWidths", () => { expect(result.totalWidth).toBe(40); expect(result.totalWidth).toBeLessThanOrEqual(terminalWidth); - expect(result.droppedIndices).toEqual([]); + expect(result.widths.every((width) => width !== undefined)).toBe(true); } }); } @@ -57,17 +57,14 @@ describe("computeColumnWidths", () => { test("shrinks and drops fixed columns from right to left", () => { expect(computeColumnWidths(runtimeColumns, 40, { selectable: true, borderWidth: 0 })).toEqual({ widths: [23, 10, 3, undefined, undefined], - droppedIndices: [3, 4], totalWidth: 40, }); expect(computeColumnWidths(runtimeColumns, 60, { selectable: true, borderWidth: 0 })).toEqual({ widths: [29, 10, 3, 13, undefined], - droppedIndices: [4], totalWidth: 60, }); expect(computeColumnWidths(runtimeColumns, 80, { selectable: true, borderWidth: 0 })).toEqual({ widths: [30, 10, 5, 13, 16], - droppedIndices: [], totalWidth: 80, }); }); @@ -116,7 +113,6 @@ describe("computeColumnWidths", () => { }); expect(result.widths).toEqual([undefined, undefined, undefined]); - expect(result.droppedIndices).toEqual([0, 1, 2]); expect(result.totalWidth).toBe(SELECTION_MARKER_WIDTH); }); diff --git a/src/components/ui/data-table/useColumnWidths.ts b/src/components/ui/data-table/columnWidths.ts similarity index 79% rename from src/components/ui/data-table/useColumnWidths.ts rename to src/components/ui/data-table/columnWidths.ts index bea81ad19..713091027 100644 --- a/src/components/ui/data-table/useColumnWidths.ts +++ b/src/components/ui/data-table/columnWidths.ts @@ -1,20 +1,13 @@ -import stringWidth from "string-width"; - export const COLUMN_GAP = 1; -export const COLUMN_MIN_WIDTH = 4; export const FLEX_MIN_WIDTH = 16; export const SELECTION_MARKER_WIDTH = 1; -export interface ColumnSizing { - header: string; - flex?: boolean; - width?: number; - minWidth?: number; -} +export type ColumnSizing = + | { flex: true; width?: never; minWidth?: never } + | { flex?: false; width: number; minWidth: number }; export interface ComputedColumnWidths { widths: (number | undefined)[]; - droppedIndices: number[]; totalWidth: number; } @@ -43,15 +36,13 @@ export function computeColumnWidths( const markerWidth = options.selectable ? SELECTION_MARKER_WIDTH : 0; const flexFloor = flexIndex === -1 ? 0 : FLEX_MIN_WIDTH; const fixedColumns: FixedColumnWidth[] = columns.flatMap((column, index) => { - if (index === flexIndex) return []; + if (column.flex === true) return []; - const naturalWidth = Math.max(stringWidth(column.header), COLUMN_MIN_WIDTH); - const width = column.width ?? naturalWidth; return [ { index, - width, - minWidth: Math.min(column.minWidth ?? COLUMN_MIN_WIDTH, width), + width: column.width, + minWidth: Math.min(column.minWidth, column.width), }, ]; }); @@ -77,9 +68,8 @@ export function computeColumnWidths( } } - const droppedIndices: number[] = []; while (totalAtFloor() > frameWidth && fixedColumns.length > 0) { - droppedIndices.push(fixedColumns.pop()!.index); + fixedColumns.pop(); } const widths: (number | undefined)[] = Array.from({ length: columns.length }); @@ -99,7 +89,6 @@ export function computeColumnWidths( return { widths, - droppedIndices: droppedIndices.sort((left, right) => left - right), totalWidth, }; } diff --git a/src/components/ui/data-table/index.ts b/src/components/ui/data-table/index.ts index ba45a6673..592e2f416 100644 --- a/src/components/ui/data-table/index.ts +++ b/src/components/ui/data-table/index.ts @@ -1,10 +1,2 @@ export { DataTable } from "./DataTable.js"; export type { DataTableProps, DataTableColumn } from "./DataTable.js"; -export { - COLUMN_GAP, - COLUMN_MIN_WIDTH, - computeColumnWidths, - FLEX_MIN_WIDTH, - resolveBorderWidth, - SELECTION_MARKER_WIDTH, -} from "./useColumnWidths.js"; From 1215f5d51e914ab9b61f805711817fcc1a674913 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 29 Jul 2026 04:14:46 +0000 Subject: [PATCH 6/6] refactor: simplify data table width configuration --- src/components/HarnessEndpointPicker.tsx | 5 +- src/components/HarnessPicker.tsx | 5 +- src/components/HarnessVersionPicker.tsx | 2 +- src/components/RuntimeEndpointPicker.tsx | 5 +- src/components/RuntimePicker.tsx | 6 +-- src/components/RuntimeVersionPicker.tsx | 2 +- .../ui/data-table/DataTable.test.tsx | 2 +- src/components/ui/data-table/DataTable.tsx | 12 +++-- .../ui/data-table/columnWidths.test.ts | 50 +++++++++++++++---- src/components/ui/data-table/columnWidths.ts | 49 +++++++++--------- .../harness/list/list.screen.test.tsx | 2 +- src/handlers/runtime/runtime.screen.test.tsx | 2 +- 12 files changed, 86 insertions(+), 56 deletions(-) diff --git a/src/components/HarnessEndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx index 4a07a685a..b479d5615 100644 --- a/src/components/HarnessEndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -18,13 +18,12 @@ interface EndpointRow extends Record { export const harnessEndpointColumns = [ { key: "endpointName", header: "name", flex: true }, { key: "liveVersion", header: "live", width: 6, minWidth: 5 }, - { key: "targetVersion", header: "target", width: 6, minWidth: 6 }, - { key: "status", header: "status", width: 13, minWidth: 13 }, + { key: "targetVersion", header: "target", width: 6 }, + { key: "status", header: "status", width: 13 }, { key: "updatedAt", header: "updated UTC", width: 16, - minWidth: 16, render: formatTimestamp, }, ] satisfies DataTableColumn[]; diff --git a/src/components/HarnessPicker.tsx b/src/components/HarnessPicker.tsx index efdda49e6..85b7a5bb6 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -19,13 +19,12 @@ interface HarnessRow extends Record { export const harnessColumns = [ { key: "harnessName", header: "name", flex: true }, - { key: "harnessVersion", header: "ver", width: 5, minWidth: 3 }, - { key: "status", header: "status", width: 13, minWidth: 13 }, + { key: "harnessVersion", header: "version", width: 7 }, + { key: "status", header: "status", width: 13 }, { key: "updatedAt", header: "updated UTC", width: 16, - minWidth: 16, render: formatTimestamp, }, ] satisfies DataTableColumn[]; diff --git a/src/components/HarnessVersionPicker.tsx b/src/components/HarnessVersionPicker.tsx index 01e87e6a2..e99f103d3 100644 --- a/src/components/HarnessVersionPicker.tsx +++ b/src/components/HarnessVersionPicker.tsx @@ -15,7 +15,7 @@ interface VersionRow extends Record { } export const harnessVersionColumns = [ - { key: "harnessVersion", header: "version", width: 7, minWidth: 4 }, + { key: "harnessVersion", header: "version", width: 7 }, { key: "status", header: "status", width: 13, minWidth: 6 }, { key: "createdAt", diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index 692c8d767..8485b46d3 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -17,13 +17,12 @@ interface RuntimeEndpointRow extends Record { export const runtimeEndpointColumns = [ { key: "qualifier", header: "qualifier", flex: true }, { key: "liveVersion", header: "live", width: 6, minWidth: 5 }, - { key: "targetVersion", header: "target", width: 6, minWidth: 6 }, - { key: "status", header: "status", width: 13, minWidth: 13 }, + { key: "targetVersion", header: "target", width: 6 }, + { key: "status", header: "status", width: 13 }, { key: "lastUpdatedAt", header: "updated UTC", width: 16, - minWidth: 16, render: formatTimestamp, }, ] satisfies DataTableColumn[]; diff --git a/src/components/RuntimePicker.tsx b/src/components/RuntimePicker.tsx index 874fc2b2b..22ef489a0 100644 --- a/src/components/RuntimePicker.tsx +++ b/src/components/RuntimePicker.tsx @@ -25,16 +25,14 @@ export const runtimeColumns = [ key: "runtimeId", header: "id suffix", width: 10, - minWidth: 10, render: runtimeIdSuffix, }, - { key: "runtimeVersion", header: "ver", width: 5, minWidth: 3 }, - { key: "status", header: "status", width: 13, minWidth: 13 }, + { key: "runtimeVersion", header: "version", width: 7 }, + { key: "status", header: "status", width: 13 }, { key: "lastUpdatedAt", header: "updated UTC", width: 16, - minWidth: 16, render: formatTimestamp, }, ] satisfies DataTableColumn[]; diff --git a/src/components/RuntimeVersionPicker.tsx b/src/components/RuntimeVersionPicker.tsx index 45233d997..2989300d7 100644 --- a/src/components/RuntimeVersionPicker.tsx +++ b/src/components/RuntimeVersionPicker.tsx @@ -13,7 +13,7 @@ interface RuntimeVersionRow extends Record { } export const runtimeVersionColumns = [ - { key: "version", header: "version", width: 7, minWidth: 4 }, + { key: "version", header: "version", width: 7 }, { key: "status", header: "status", width: 13, minWidth: 6 }, { key: "lastUpdatedAt", diff --git a/src/components/ui/data-table/DataTable.test.tsx b/src/components/ui/data-table/DataTable.test.tsx index 325029683..b580ff783 100644 --- a/src/components/ui/data-table/DataTable.test.tsx +++ b/src/components/ui/data-table/DataTable.test.tsx @@ -13,7 +13,7 @@ interface Row extends Record { const columns: DataTableColumn[] = [ { key: "name", header: "name", flex: true }, - { key: "status", header: "status", width: 13, minWidth: 13 }, + { key: "status", header: "status", width: 13 }, ]; function renderTableAt(columnsWide: number) { diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index b60c99cb2..f5d337e1f 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -12,14 +12,12 @@ import { } from "./columnWidths.js"; import type { ColumnSizing } from "./columnWidths.js"; -interface DataTableColumnBase { +export type DataTableColumn = { key: keyof T & string; header: string; align?: "left" | "center" | "right"; render?: (value: unknown, row: T) => string; -} - -export type DataTableColumn = DataTableColumnBase & ColumnSizing; +} & ColumnSizing; export interface DataTableProps { columns: readonly DataTableColumn[]; @@ -180,7 +178,11 @@ export function DataTable>({ : borderStyle === "bold" ? "bold" : "single"; - const borderWidth = resolveBorderWidth(bord, borderLeft, borderRight); + const borderWidth = resolveBorderWidth({ + style: bord, + left: borderLeft, + right: borderRight, + }); const computedWidths = computeColumnWidths(columns, terminalWidth, { selectable, borderWidth, diff --git a/src/components/ui/data-table/columnWidths.test.ts b/src/components/ui/data-table/columnWidths.test.ts index 594cf0a7f..c33586727 100644 --- a/src/components/ui/data-table/columnWidths.test.ts +++ b/src/components/ui/data-table/columnWidths.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import stringWidth from "string-width"; import { harnessEndpointColumns } from "../../HarnessEndpointPicker"; import { harnessColumns } from "../../HarnessPicker"; import { harnessVersionColumns } from "../../HarnessVersionPicker"; @@ -54,21 +55,50 @@ describe("computeColumnWidths", () => { }); } - test("shrinks and drops fixed columns from right to left", () => { + test("drops fixed columns from right to left without truncating headers", () => { expect(computeColumnWidths(runtimeColumns, 40, { selectable: true, borderWidth: 0 })).toEqual({ - widths: [23, 10, 3, undefined, undefined], + widths: [19, 10, 7, undefined, undefined], totalWidth: 40, }); expect(computeColumnWidths(runtimeColumns, 60, { selectable: true, borderWidth: 0 })).toEqual({ - widths: [29, 10, 3, 13, undefined], + widths: [25, 10, 7, 13, undefined], totalWidth: 60, }); expect(computeColumnWidths(runtimeColumns, 80, { selectable: true, borderWidth: 0 })).toEqual({ - widths: [30, 10, 5, 13, 16], + widths: [28, 10, 7, 13, 16], totalWidth: 80, }); }); + test("defaults minWidth to width and honors explicit shrink floors", () => { + const columns = [{ width: 6 }, { width: 6, minWidth: 4 }]; + + expect(computeColumnWidths(columns, 11, { selectable: false, borderWidth: 0 })).toEqual({ + widths: [6, 4], + totalWidth: 11, + }); + expect(computeColumnWidths(columns, 10, { selectable: false, borderWidth: 0 })).toEqual({ + widths: [6, undefined], + totalWidth: 6, + }); + }); + + test("keeps every visible production header intact", () => { + for (const config of [...flexConfigs, ...fixedConfigs]) { + for (let terminalWidth = 1; terminalWidth <= 200; terminalWidth += 1) { + const result = computeColumnWidths(config.columns, terminalWidth, { + selectable: true, + borderWidth: 0, + }); + + config.columns.forEach((column, index) => { + const width = result.widths[index]; + if (width !== undefined) expect(width).toBeGreaterThanOrEqual(stringWidth(column.header)); + }); + } + } + }); + test("preserves all legal version digits at normal terminal widths", () => { const runtime = computeColumnWidths(runtimeColumns, 80, { selectable: true, @@ -83,8 +113,8 @@ describe("computeColumnWidths", () => { borderWidth: 0, }); - expect(runtime.widths[2]).toBe(5); - expect(harness.widths[1]).toBe(5); + expect(runtime.widths[2]).toBe(7); + expect(harness.widths[1]).toBe(7); expect(endpoint.widths[1]).toBe(6); expect(endpoint.widths[2]).toBe(6); }); @@ -117,7 +147,7 @@ describe("computeColumnWidths", () => { }); test("accounts for resolved borders", () => { - const borderWidth = resolveBorderWidth("single", undefined, undefined); + const borderWidth = resolveBorderWidth({ style: "single" }); const result = computeColumnWidths(runtimeColumns, 80, { selectable: true, borderWidth, @@ -125,8 +155,8 @@ describe("computeColumnWidths", () => { expect(borderWidth).toBe(2); expect(result.totalWidth + borderWidth).toBe(80); - expect(resolveBorderWidth("single", false, undefined)).toBe(1); - expect(resolveBorderWidth("single", false, false)).toBe(0); - expect(resolveBorderWidth(undefined, undefined, undefined)).toBe(0); + expect(resolveBorderWidth({ style: "single", left: false })).toBe(1); + expect(resolveBorderWidth({ style: "single", left: false, right: false })).toBe(0); + expect(resolveBorderWidth({})).toBe(0); }); }); diff --git a/src/components/ui/data-table/columnWidths.ts b/src/components/ui/data-table/columnWidths.ts index 713091027..8e57b7a99 100644 --- a/src/components/ui/data-table/columnWidths.ts +++ b/src/components/ui/data-table/columnWidths.ts @@ -4,26 +4,30 @@ export const SELECTION_MARKER_WIDTH = 1; export type ColumnSizing = | { flex: true; width?: never; minWidth?: never } - | { flex?: false; width: number; minWidth: number }; + | { flex?: false; width: number; minWidth?: number }; -export interface ComputedColumnWidths { +export type ComputedColumnWidths = { widths: (number | undefined)[]; totalWidth: number; -} +}; -interface FixedColumnWidth { +type FixedColumnWidth = { index: number; width: number; minWidth: number; -} +}; -export function resolveBorderWidth( - borderStyle: string | undefined, - borderLeft: boolean | undefined, - borderRight: boolean | undefined, -): number { - if (borderStyle === undefined) return 0; - return Number(borderLeft !== false) + Number(borderRight !== false); +export function resolveBorderWidth({ + style, + left, + right, +}: { + style?: string; + left?: boolean; + right?: boolean; +}): number { + if (style === undefined) return 0; + return Number(left !== false) + Number(right !== false); } export function computeColumnWidths( @@ -42,21 +46,22 @@ export function computeColumnWidths( { index, width: column.width, - minWidth: Math.min(column.minWidth, column.width), + minWidth: Math.min(column.minWidth ?? column.width, column.width), }, ]; }); - const gapCount = () => { + const visibleFixedWidth = () => fixedColumns.reduce((total, column) => total + column.width, 0); + const gapWidth = () => { const visibleColumns = fixedColumns.length + (flexIndex === -1 ? 0 : 1); + // Ink inserts columnGap between adjacent children; the selection marker is also a child. const childCount = visibleColumns + (options.selectable ? 1 : 0); - return Math.max(0, childCount - 1); + return Math.max(0, childCount - 1) * COLUMN_GAP; }; - const fixedWidth = () => fixedColumns.reduce((total, column) => total + column.width, 0); - const totalAtFloor = () => markerWidth + fixedWidth() + flexFloor + gapCount() * COLUMN_GAP; + const minimumTableWidth = () => markerWidth + visibleFixedWidth() + flexFloor + gapWidth(); while ( - totalAtFloor() > frameWidth && + minimumTableWidth() > frameWidth && fixedColumns.some((column) => column.width > column.minWidth) ) { for (let index = fixedColumns.length - 1; index >= 0; index -= 1) { @@ -68,7 +73,7 @@ export function computeColumnWidths( } } - while (totalAtFloor() > frameWidth && fixedColumns.length > 0) { + while (minimumTableWidth() > frameWidth && fixedColumns.length > 0) { fixedColumns.pop(); } @@ -78,14 +83,12 @@ export function computeColumnWidths( if (flexIndex !== -1) { widths[flexIndex] = Math.max( FLEX_MIN_WIDTH, - frameWidth - markerWidth - fixedWidth() - gapCount() * COLUMN_GAP, + frameWidth - markerWidth - visibleFixedWidth() - gapWidth(), ); } const totalWidth = - markerWidth + - widths.reduce((total, width) => total + (width ?? 0), 0) + - gapCount() * COLUMN_GAP; + markerWidth + widths.reduce((total, width) => total + (width ?? 0), 0) + gapWidth(); return { widths, diff --git a/src/handlers/harness/list/list.screen.test.tsx b/src/handlers/harness/list/list.screen.test.tsx index 5f9605e92..54701e384 100644 --- a/src/handlers/harness/list/list.screen.test.tsx +++ b/src/handlers/harness/list/list.screen.test.tsx @@ -46,7 +46,7 @@ describe("harness list screen", () => { expect(frame).toContain("beta"); expect(frame).toContain("READY"); expect(frame).toContain("name"); - expect(frame).toContain("ver"); + expect(frame).toContain("version"); expect(frame).toContain("99999"); expect(frame).toContain("status"); expect(frame).toContain("updated UTC"); diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 3e0e26902..afaa84786 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -87,7 +87,7 @@ describe("runtime picker", () => { expect(frame).toContain("name"); expect(frame).toContain("id"); expect(frame).toContain("id suffix"); - expect(frame).toContain("ver"); + expect(frame).toContain("version"); expect(frame).toContain("status"); expect(frame).toContain("updated UTC"); expect(frame).toContain("AbCdEf1234");