Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/components/ResourceDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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 (
<Layout
breadcrumb={breadcrumb}
keyHints={[
...(ready && actions.length > 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 ? (
<Spinner label={loadingLabel} />
) : error ? (
<Text color="red">Error: {error.message}</Text>
) : (
<Box flexDirection="column">
<Box flexDirection="column" paddingLeft={1}>
<KeyValueTable items={items} />
</Box>

{actions.length > 0 && (
<>
<Divider />

<Box flexDirection="column" paddingLeft={1}>
{actions.map((action, actionIndex) => {
const selected = actionIndex === selectedIndex;
return (
<Box key={action.name}>
<Text color={darkTheme.colors.focus}>{selected ? "❯ " : " "}</Text>
<Text
bold={selected}
color={selected ? darkTheme.colors.focus : darkTheme.colors.text}
>
{action.name.padEnd(nameWidth)}
</Text>
<Text color={darkTheme.colors.muted}>{action.description}</Text>
</Box>
);
})}
</Box>
</>
)}
</Box>
)}
</Layout>
);
}
11 changes: 11 additions & 0 deletions src/handlers/harness/get/get.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
121 changes: 32 additions & 89 deletions src/handlers/harness/get/screen.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 (
<Layout
<ResourceDetailScreen
breadcrumb={["agentcore", "harness", "get", harnessId ?? ""]}
keyHints={[
{ key: "↑↓/kj", label: "navigate" },
{ key: "enter", label: "select" },
{ key: "esc", label: "back" },
{ key: "ctl+c", label: "quit" },
]}
>
{detail.isPending ? (
<Spinner label="Loading harness…" />
) : detail.isError ? (
<Text color="red">Error: {(detail.error as Error).message}</Text>
) : (
<Box flexDirection="column">
{/* Summary overlay */}
<Box flexDirection="column" paddingLeft={1}>
<KeyValueTable
items={{
id: harness?.harnessId ?? "",
status: harness?.status ?? "",
version: harness?.harnessVersion?.toString() ?? "0",
arn: harness?.arn ?? "",
}}
/>
</Box>

<Divider />

{/* Action selector */}
<Box flexDirection="column" paddingLeft={1}>
{ACTIONS.map((action, i) => {
const isHl = i === index;
return (
<Box key={action.name}>
<Text color={theme.colors.focus}>{isHl ? "❯ " : " "}</Text>
<Text bold={isHl} color={isHl ? theme.colors.focus : theme.colors.text}>
{action.name.padEnd(nameWidth)}
</Text>
<Text color={theme.colors.muted}>{action.description}</Text>
</Box>
);
})}
</Box>
</Box>
)}
</Layout>
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 (
<JsonDetail
Expand All @@ -156,6 +98,7 @@ export function HarnessGetJsonScreen({ ctx, core }: ScreenProps) {
error={detail.isError ? (detail.error as Error) : null}
data={detail.data?.harness}
loadingLabel="Loading harness…"
onRetry={() => void detail.refetch()}
/>
);
}
92 changes: 30 additions & 62 deletions src/handlers/memory/get/screen.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
<Layout
<ResourceDetailScreen
breadcrumb={["agentcore", "memory", "get", memoryId ?? ""]}
keyHints={[
...(!detail.isPending && !detail.isError ? [{ key: "enter", label: "open detail" }] : []),
...(detail.isError ? [{ key: "r", label: "retry" }] : []),
{ key: "esc", label: "back" },
{ key: "ctl+c", label: "quit" },
]}
>
{detail.isPending ? (
<Spinner label="Loading Memory…" />
) : detail.isError ? (
<Text color="red">Error: {(detail.error as Error).message}</Text>
) : (
<Box flexDirection="column">
<Box flexDirection="column" paddingLeft={1}>
<KeyValueTable
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 ?? "",
}}
/>
</Box>

<Divider />

<Box paddingLeft={1}>
<Text color={darkTheme.colors.focus}>❯ </Text>
<Text bold color={darkTheme.colors.focus}>
{"detail".padEnd(9)}
</Text>
<Text color={darkTheme.colors.muted}>show the full JSON definition</Text>
</Box>
</Box>
)}
</Layout>
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"
/>
);
}

Expand Down
Loading
Loading