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..b479d5615 100644 --- a/src/components/HarnessEndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -2,7 +2,9 @@ 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 type { DataTableColumn } from "./ui/data-table"; // EndpointRow is the flat, display-ready shape the table renders. interface EndpointRow extends Record { @@ -13,6 +15,19 @@ 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 }, + { key: "status", header: "status", width: 13 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + function toRow(e: HarnessEndpoint): EndpointRow { return { endpointName: e.endpointName!, @@ -70,13 +85,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..85b7a5bb6 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -2,7 +2,9 @@ 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 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,18 @@ interface HarnessRow extends Record { status: string; } +export const harnessColumns = [ + { key: "harnessName", header: "name", flex: true }, + { key: "harnessVersion", header: "version", width: 7 }, + { key: "status", header: "status", width: 13 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + // toRow flattens a HarnessSummary into a HarnessRow, formatting dates. function toRow(h: HarnessSummary): HarnessRow { return { @@ -68,12 +82,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..e99f103d3 100644 --- a/src/components/HarnessVersionPicker.tsx +++ b/src/components/HarnessVersionPicker.tsx @@ -2,7 +2,9 @@ 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 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 }, + { 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..8485b46d3 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -2,7 +2,9 @@ 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 type { DataTableColumn } from "./ui/data-table"; interface RuntimeEndpointRow extends Record { qualifier: string; @@ -12,6 +14,19 @@ 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 }, + { key: "status", header: "status", width: 13 }, + { + key: "lastUpdatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + function toRow(endpoint: AgentRuntimeEndpoint): RuntimeEndpointRow { return { qualifier: endpoint.name ?? endpoint.id ?? "", @@ -54,13 +69,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..22ef489a0 100644 --- a/src/components/RuntimePicker.tsx +++ b/src/components/RuntimePicker.tsx @@ -2,7 +2,9 @@ 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 type { DataTableColumn } from "./ui/data-table"; interface RuntimeRow extends Record { runtimeId: string; @@ -12,6 +14,29 @@ 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 }, + { + key: "runtimeId", + header: "id suffix", + width: 10, + render: runtimeIdSuffix, + }, + { key: "runtimeVersion", header: "version", width: 7 }, + { key: "status", header: "status", width: 13 }, + { + key: "lastUpdatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + function toRow(runtime: AgentRuntime): RuntimeRow { const runtimeId = runtime.agentRuntimeId ?? ""; return { @@ -53,13 +78,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..2989300d7 100644 --- a/src/components/RuntimeVersionPicker.tsx +++ b/src/components/RuntimeVersionPicker.tsx @@ -2,7 +2,9 @@ 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 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 }, + { 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/formatTimestamp.test.ts b/src/components/formatTimestamp.test.ts new file mode 100644 index 000000000..1b52ac3f5 --- /dev/null +++ b/src/components/formatTimestamp.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { formatTimestamp } from "./formatTimestamp"; + +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"); + }); + + test("renders invalid timestamps as blank values", () => { + for (const value of [undefined, null, "", "garbage"]) { + expect(formatTimestamp(value)).toBe(""); + } + }); +}); diff --git a/src/components/formatTimestamp.ts b/src/components/formatTimestamp.ts new file mode 100644 index 000000000..c46881640 --- /dev/null +++ b/src/components/formatTimestamp.ts @@ -0,0 +1,5 @@ +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", " "); +} 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..b580ff783 --- /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 }, +]; + +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..f5d337e1f 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -1,19 +1,26 @@ 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 "./columnWidths.js"; +import type { ColumnSizing } from "./columnWidths.js"; -export interface DataTableColumn { +export type DataTableColumn = { key: keyof T & string; header: string; align?: "left" | "center" | "right"; - sortable?: boolean; render?: (value: unknown, row: T) => string; - width?: number; -} +} & ColumnSizing; export interface DataTableProps { - columns: DataTableColumn[]; + columns: readonly DataTableColumn[]; data: T[]; pageSize?: number; searchable?: boolean; @@ -66,10 +73,9 @@ 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); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const [searchQuery, setSearchQuery] = useState(""); const [searchMode, setSearchMode] = useState(false); @@ -82,22 +88,15 @@ 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); }); }); - // 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) => { @@ -148,15 +147,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); @@ -166,25 +156,20 @@ 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); }; + 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" ? undefined @@ -193,6 +178,19 @@ export function DataTable>({ : borderStyle === "bold" ? "bold" : "single"; + const borderWidth = resolveBorderWidth({ + style: bord, + left: borderLeft, + right: borderRight, + }); + const computedWidths = computeColumnWidths(columns, terminalWidth, { + selectable, + borderWidth, + }); + const columnsWithWidths = columns.flatMap((column, index) => { + const width = computedWidths.widths[index]; + return width === undefined ? [] : [{ column, width }]; + }); return ( @@ -224,28 +222,25 @@ export function DataTable>({ borderRight={borderRight} > {/* Header */} - - {selectable && {" "}} - {columnsWithWidths.map((col) => { - const sortArrow = sortColumn === col.key ? (sortDirection === "asc" ? " ▲" : " ▼") : ""; - return ( - - - {pad(col.header + sortArrow, col.width, col.align)} - - - ); - })} + + {selectable && ( + + + + )} + {columnsWithWidths.map(({ column, width }) => ( + + + {pad(column.header, width, column.align)} + + + ))} {/* 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 +253,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)} ); @@ -290,9 +291,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/columnWidths.test.ts b/src/components/ui/data-table/columnWidths.test.ts new file mode 100644 index 000000000..c33586727 --- /dev/null +++ b/src/components/ui/data-table/columnWidths.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test"; +import stringWidth from "string-width"; +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 "./columnWidths"; + +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.widths.every((width) => width !== undefined)).toBe(true); + } + }); + } + + test("drops fixed columns from right to left without truncating headers", () => { + expect(computeColumnWidths(runtimeColumns, 40, { selectable: true, borderWidth: 0 })).toEqual({ + widths: [19, 10, 7, undefined, undefined], + totalWidth: 40, + }); + expect(computeColumnWidths(runtimeColumns, 60, { selectable: true, borderWidth: 0 })).toEqual({ + widths: [25, 10, 7, 13, undefined], + totalWidth: 60, + }); + expect(computeColumnWidths(runtimeColumns, 80, { selectable: true, borderWidth: 0 })).toEqual({ + 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, + 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(7); + expect(harness.widths[1]).toBe(7); + 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.totalWidth).toBe(SELECTION_MARKER_WIDTH); + }); + + test("accounts for resolved borders", () => { + const borderWidth = resolveBorderWidth({ style: "single" }); + const result = computeColumnWidths(runtimeColumns, 80, { + selectable: true, + borderWidth, + }); + + expect(borderWidth).toBe(2); + expect(result.totalWidth + borderWidth).toBe(80); + 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 new file mode 100644 index 000000000..8e57b7a99 --- /dev/null +++ b/src/components/ui/data-table/columnWidths.ts @@ -0,0 +1,97 @@ +export const COLUMN_GAP = 1; +export const FLEX_MIN_WIDTH = 16; +export const SELECTION_MARKER_WIDTH = 1; + +export type ColumnSizing = + | { flex: true; width?: never; minWidth?: never } + | { flex?: false; width: number; minWidth?: number }; + +export type ComputedColumnWidths = { + widths: (number | undefined)[]; + totalWidth: number; +}; + +type FixedColumnWidth = { + index: number; + width: number; + minWidth: number; +}; + +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( + 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 (column.flex === true) return []; + + return [ + { + index, + width: column.width, + minWidth: Math.min(column.minWidth ?? column.width, column.width), + }, + ]; + }); + + 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) * COLUMN_GAP; + }; + const minimumTableWidth = () => markerWidth + visibleFixedWidth() + flexFloor + gapWidth(); + + while ( + minimumTableWidth() > 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; + } + } + } + + while (minimumTableWidth() > frameWidth && fixedColumns.length > 0) { + fixedColumns.pop(); + } + + 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 - visibleFixedWidth() - gapWidth(), + ); + } + + const totalWidth = + markerWidth + widths.reduce((total, width) => total + (width ?? 0), 0) + gapWidth(); + + return { + widths, + 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..54701e384 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 }); @@ -47,8 +47,10 @@ describe("harness list screen", () => { expect(frame).toContain("READY"); expect(frame).toContain("name"); expect(frame).toContain("version"); + 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..afaa84786 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("version"); 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"); });