diff --git a/src/components/ResourceDetailScreen.tsx b/src/components/ResourceDetailScreen.tsx new file mode 100644 index 000000000..1b967ff7f --- /dev/null +++ b/src/components/ResourceDetailScreen.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { useNavigate } from "react-router"; +import { KeyValueTable } from "./KeyValueTable.js"; +import { Layout } from "./Layout"; +import { darkTheme } from "./ui/_core.js"; +import { Divider } from "./ui/divider/Divider.js"; +import { Spinner } from "./ui/spinner"; + +export interface ResourceDetailAction { + name: string; + description: string; + onSelect: () => void; +} + +export interface ResourceDetailScreenProps { + breadcrumb: string[]; + isPending: boolean; + error: Error | null; + items: Record; + actions: ResourceDetailAction[]; + loadingLabel: string; + onRetry?: () => void; + selectLabel?: string; +} + +export function ResourceDetailScreen({ + breadcrumb, + isPending, + error, + items, + actions, + loadingLabel, + onRetry, + selectLabel = "select", +}: ResourceDetailScreenProps) { + const navigate = useNavigate(); + const [selectedIndex, setSelectedIndex] = useState(0); + const ready = !isPending && !error; + + useInput((input, key) => { + if (key.escape) { + navigate(-1); + return; + } + if (input === "r" && error && onRetry) { + onRetry(); + return; + } + if (!ready || actions.length === 0) return; + if (key.upArrow || input === "k") { + setSelectedIndex((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow || input === "j") { + setSelectedIndex((current) => Math.min(actions.length - 1, current + 1)); + return; + } + if (key.return) actions[selectedIndex]?.onSelect(); + }); + + const nameWidth = actions.reduce((width, action) => Math.max(width, action.name.length), 0) + 3; + + return ( + 1 ? [{ key: "↑↓/jk", label: "navigate" }] : []), + ...(ready && actions.length > 0 ? [{ key: "enter", label: selectLabel }] : []), + ...(error && onRetry ? [{ key: "r", label: "retry" }] : []), + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ]} + > + {isPending ? ( + + ) : error ? ( + Error: {error.message} + ) : ( + + + + + + {actions.length > 0 && ( + <> + + + + {actions.map((action, actionIndex) => { + const selected = actionIndex === selectedIndex; + return ( + + {selected ? "❯ " : " "} + + {action.name.padEnd(nameWidth)} + + {action.description} + + ); + })} + + + )} + + )} + + ); +} diff --git a/src/handlers/harness/get/get.screen.test.tsx b/src/handlers/harness/get/get.screen.test.tsx index 18f918a1a..61a82c157 100644 --- a/src/handlers/harness/get/get.screen.test.tsx +++ b/src/handlers/harness/get/get.screen.test.tsx @@ -95,6 +95,17 @@ describe("harness hub screen", () => { r.unmount(); }); + test("up navigation returns to the previous action", async () => { + const { r } = hubScreen(); + + await waitForText(r.lastFrame, "detail"); + await r.press("down"); + await r.press("up"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → harness → get → MyHarness-abc123 → json"); + r.unmount(); + }); + test("enter on `endpoints` opens this harness's endpoint list", async () => { const { core, r } = hubScreen(); core.harness.setListEndpointsResponse({ diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index 75a68b510..2f86f1c2f 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -1,17 +1,9 @@ -import { useState } from "react"; -import { Box, Text, useInput } from "ink"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { Spinner } from "../../../components/ui/spinner"; -import { Layout } from "../../../components/Layout"; import { JsonDetail } from "../../../components/JsonDetail"; -import { darkTheme } from "../../../components/ui/_core.js"; -import { KeyValueTable } from "../../../components/KeyValueTable.js"; -import { Divider } from "../../../components/ui/divider/Divider.js"; - -const theme = darkTheme; +import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; // The actions offered for a harness, in menu order. Each routes into the // corresponding flow with the harness preselected. @@ -52,102 +44,52 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string } // ARN, execution role, status) above an action selector that jumps into the // harness's flows (detail JSON, endpoints, versions, invoke, exec). The harness // ID comes from the `:harnessId` route path value. -export function HarnessGetScreen({ ctx, core }: ScreenProps) { +function useHarnessDetail({ ctx, core }: ScreenProps, harnessId: string | undefined) { const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); - const { harnessId } = useParams(); - - const detail = useQuery({ + return useQuery({ queryKey: ["harness", opts.region, harnessId], queryFn: () => core.harness.getHarness(harnessId!, opts), enabled: harnessId !== undefined, }); +} - const [index, setIndex] = useState(0); - - useInput((input, key) => { - if (key.escape) { - navigate(-1); - return; - } - if (key.upArrow || input === "k") { - setIndex((i) => Math.max(0, i - 1)); - return; - } - if (key.downArrow || input == "j") { - setIndex((i) => Math.min(ACTIONS.length - 1, i + 1)); - return; - } - if (key.return && harnessId) { - navigate(ACTIONS[index]!.to(harnessId)); - } - }); - +export function HarnessGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { harnessId } = useParams(); + const detail = useHarnessDetail(props, harnessId); const harness = detail.data?.harness; - const nameWidth = ACTIONS.reduce((m, a) => Math.max(m, a.name.length), 0) + 3; return ( - - {detail.isPending ? ( - - ) : detail.isError ? ( - Error: {(detail.error as Error).message} - ) : ( - - {/* Summary overlay */} - - - - - - - {/* Action selector */} - - {ACTIONS.map((action, i) => { - const isHl = i === index; - return ( - - {isHl ? "❯ " : " "} - - {action.name.padEnd(nameWidth)} - - {action.description} - - ); - })} - - - )} - + isPending={detail.isPending} + error={detail.isError ? (detail.error as Error) : null} + items={{ + id: harness?.harnessId ?? "", + status: harness?.status ?? "", + version: harness?.harnessVersion?.toString() ?? "0", + arn: harness?.arn ?? "", + }} + actions={ + harnessId && harness + ? ACTIONS.map((action) => ({ + name: action.name, + description: action.description, + onSelect: () => navigate(action.to(harnessId)), + })) + : [] + } + loadingLabel="Loading harness…" + onRetry={() => void detail.refetch()} + /> ); } // HarnessGetJsonScreen renders the harness's full definition as scrollable JSON // (the hub's "detail" action). -export function HarnessGetJsonScreen({ ctx, core }: ScreenProps) { - const opts = coreOptsFromCtx(ctx); +export function HarnessGetJsonScreen(props: ScreenProps) { const { harnessId } = useParams(); - - const detail = useQuery({ - queryKey: ["harness", opts.region, harnessId], - queryFn: () => core.harness.getHarness(harnessId!, opts), - enabled: harnessId !== undefined, - }); + const detail = useHarnessDetail(props, harnessId); return ( void detail.refetch()} /> ); } diff --git a/src/handlers/memory/get/screen.tsx b/src/handlers/memory/get/screen.tsx index 643ffbd3e..ec37ac090 100644 --- a/src/handlers/memory/get/screen.tsx +++ b/src/handlers/memory/get/screen.tsx @@ -1,12 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { Box, Text, useInput } from "ink"; import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../components/JsonDetail"; -import { KeyValueTable } from "../../../components/KeyValueTable.js"; -import { Layout } from "../../../components/Layout"; -import { darkTheme } from "../../../components/ui/_core.js"; -import { Divider } from "../../../components/ui/divider/Divider.js"; -import { Spinner } from "../../../components/ui/spinner"; +import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -25,64 +20,37 @@ export function MemoryGetScreen(props: ScreenProps) { const detail = useMemoryDetail(props, memoryId); const memory = detail.data?.memory; - useInput((input, key) => { - if (key.escape) { - navigate(-1); - return; - } - if (input === "r" && detail.isError) { - void detail.refetch(); - return; - } - if (detail.isError || !memory) return; - if (key.return && memoryId) { - navigate(`/agentcore/memory/get/${encodeURIComponent(memoryId)}/json`); - } - }); - return ( - - {detail.isPending ? ( - - ) : detail.isError ? ( - Error: {(detail.error as Error).message} - ) : ( - - - - - - - - - - - {"detail".padEnd(9)} - - show the full JSON definition - - - )} - + isPending={detail.isPending} + error={detail.isError ? (detail.error as Error) : null} + items={{ + name: memory?.name ?? "", + id: memory?.id ?? "", + status: memory?.status ?? "", + eventExpiryDays: memory?.eventExpiryDuration?.toString() ?? "-", + strategies: memory?.strategies?.length.toString() ?? "0", + updatedAt: memory?.updatedAt?.toISOString() ?? "-", + ...(memory?.failureReason ? { failureReason: memory.failureReason } : {}), + arn: memory?.arn ?? "", + }} + actions={ + memoryId && memory + ? [ + { + name: "detail", + description: "show the full JSON definition", + onSelect: () => + navigate(`/agentcore/memory/get/${encodeURIComponent(memoryId)}/json`), + }, + ] + : [] + } + loadingLabel="Loading Memory…" + onRetry={() => void detail.refetch()} + selectLabel="open detail" + /> ); } diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index 892ee9340..c33528ec1 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -1,13 +1,7 @@ -import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Box, Text, useInput } from "ink"; import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../components/JsonDetail"; -import { KeyValueTable } from "../../../components/KeyValueTable.js"; -import { Layout } from "../../../components/Layout"; -import { darkTheme } from "../../../components/ui/_core.js"; -import { Divider } from "../../../components/ui/divider/Divider.js"; -import { Spinner } from "../../../components/ui/spinner"; +import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -29,108 +23,52 @@ const ACTIONS = [ }, ] as const; -export function RuntimeGetScreen({ ctx, core }: ScreenProps) { +function useRuntimeDetail({ ctx, core }: ScreenProps, runtimeId: string | undefined) { const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); - const { runtimeId } = useParams(); - const [index, setIndex] = useState(0); - const detail = useQuery({ + return useQuery({ queryKey: ["runtime", opts.region, runtimeId], queryFn: () => core.runtime.getRuntime(runtimeId!, opts), enabled: runtimeId !== undefined, }); +} - useInput((input, key) => { - if (key.escape) { - navigate(-1); - return; - } - if (input === "r" && detail.isError) { - void detail.refetch(); - return; - } - if (detail.isError || !detail.data) return; - if (key.upArrow || input === "k") { - setIndex((current) => Math.max(0, current - 1)); - return; - } - if (key.downArrow || input === "j") { - setIndex((current) => Math.min(ACTIONS.length - 1, current + 1)); - return; - } - if (key.return && runtimeId) navigate(ACTIONS[index]!.to(runtimeId)); - }); - - const nameWidth = ACTIONS.reduce((width, action) => Math.max(width, action.name.length), 0) + 3; +export function RuntimeGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { runtimeId } = useParams(); + const detail = useRuntimeDetail(props, runtimeId); return ( - - {detail.isPending ? ( - - ) : detail.isError ? ( - Error: {(detail.error as Error).message} - ) : ( - - - - - - - - - {ACTIONS.map((action, actionIndex) => { - const selected = actionIndex === index; - return ( - - {selected ? "❯ " : " "} - - {action.name.padEnd(nameWidth)} - - {action.description} - - ); - })} - - - )} - + isPending={detail.isPending} + error={detail.isError ? (detail.error as Error) : null} + items={{ + id: detail.data?.agentRuntimeId ?? "", + status: detail.data?.status ?? "", + ...(detail.data?.failureReason ? { failureReason: detail.data.failureReason } : {}), + version: detail.data?.agentRuntimeVersion ?? "", + protocol: detail.data?.protocolConfiguration?.serverProtocol ?? "-", + network: detail.data?.networkConfiguration?.networkMode ?? "-", + arn: detail.data?.agentRuntimeArn ?? "", + }} + actions={ + runtimeId && detail.data + ? ACTIONS.map((action) => ({ + name: action.name, + description: action.description, + onSelect: () => navigate(action.to(runtimeId)), + })) + : [] + } + loadingLabel="Loading Runtime…" + onRetry={() => void detail.refetch()} + /> ); } -export function RuntimeGetJsonScreen({ ctx, core }: ScreenProps) { - const opts = coreOptsFromCtx(ctx); +export function RuntimeGetJsonScreen(props: ScreenProps) { const { runtimeId } = useParams(); - const detail = useQuery({ - queryKey: ["runtime", opts.region, runtimeId], - queryFn: () => core.runtime.getRuntime(runtimeId!, opts), - enabled: runtimeId !== undefined, - }); + const detail = useRuntimeDetail(props, runtimeId); return (