Skip to content

Commit dd25af8

Browse files
authored
refactor(tui): share resource detail screen (#1890)
1 parent 8ec2114 commit dd25af8

5 files changed

Lines changed: 219 additions & 247 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useState } from "react";
2+
import { Box, Text, useInput } from "ink";
3+
import { useNavigate } from "react-router";
4+
import { KeyValueTable } from "./KeyValueTable.js";
5+
import { Layout } from "./Layout";
6+
import { darkTheme } from "./ui/_core.js";
7+
import { Divider } from "./ui/divider/Divider.js";
8+
import { Spinner } from "./ui/spinner";
9+
10+
export interface ResourceDetailAction {
11+
name: string;
12+
description: string;
13+
onSelect: () => void;
14+
}
15+
16+
export interface ResourceDetailScreenProps {
17+
breadcrumb: string[];
18+
isPending: boolean;
19+
error: Error | null;
20+
items: Record<string, string>;
21+
actions: ResourceDetailAction[];
22+
loadingLabel: string;
23+
onRetry?: () => void;
24+
selectLabel?: string;
25+
}
26+
27+
export function ResourceDetailScreen({
28+
breadcrumb,
29+
isPending,
30+
error,
31+
items,
32+
actions,
33+
loadingLabel,
34+
onRetry,
35+
selectLabel = "select",
36+
}: ResourceDetailScreenProps) {
37+
const navigate = useNavigate();
38+
const [selectedIndex, setSelectedIndex] = useState(0);
39+
const ready = !isPending && !error;
40+
41+
useInput((input, key) => {
42+
if (key.escape) {
43+
navigate(-1);
44+
return;
45+
}
46+
if (input === "r" && error && onRetry) {
47+
onRetry();
48+
return;
49+
}
50+
if (!ready || actions.length === 0) return;
51+
if (key.upArrow || input === "k") {
52+
setSelectedIndex((current) => Math.max(0, current - 1));
53+
return;
54+
}
55+
if (key.downArrow || input === "j") {
56+
setSelectedIndex((current) => Math.min(actions.length - 1, current + 1));
57+
return;
58+
}
59+
if (key.return) actions[selectedIndex]?.onSelect();
60+
});
61+
62+
const nameWidth = actions.reduce((width, action) => Math.max(width, action.name.length), 0) + 3;
63+
64+
return (
65+
<Layout
66+
breadcrumb={breadcrumb}
67+
keyHints={[
68+
...(ready && actions.length > 1 ? [{ key: "↑↓/jk", label: "navigate" }] : []),
69+
...(ready && actions.length > 0 ? [{ key: "enter", label: selectLabel }] : []),
70+
...(error && onRetry ? [{ key: "r", label: "retry" }] : []),
71+
{ key: "esc", label: "back" },
72+
{ key: "ctl+c", label: "quit" },
73+
]}
74+
>
75+
{isPending ? (
76+
<Spinner label={loadingLabel} />
77+
) : error ? (
78+
<Text color="red">Error: {error.message}</Text>
79+
) : (
80+
<Box flexDirection="column">
81+
<Box flexDirection="column" paddingLeft={1}>
82+
<KeyValueTable items={items} />
83+
</Box>
84+
85+
{actions.length > 0 && (
86+
<>
87+
<Divider />
88+
89+
<Box flexDirection="column" paddingLeft={1}>
90+
{actions.map((action, actionIndex) => {
91+
const selected = actionIndex === selectedIndex;
92+
return (
93+
<Box key={action.name}>
94+
<Text color={darkTheme.colors.focus}>{selected ? "❯ " : " "}</Text>
95+
<Text
96+
bold={selected}
97+
color={selected ? darkTheme.colors.focus : darkTheme.colors.text}
98+
>
99+
{action.name.padEnd(nameWidth)}
100+
</Text>
101+
<Text color={darkTheme.colors.muted}>{action.description}</Text>
102+
</Box>
103+
);
104+
})}
105+
</Box>
106+
</>
107+
)}
108+
</Box>
109+
)}
110+
</Layout>
111+
);
112+
}

src/handlers/harness/get/get.screen.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ describe("harness hub screen", () => {
9595
r.unmount();
9696
});
9797

98+
test("up navigation returns to the previous action", async () => {
99+
const { r } = hubScreen();
100+
101+
await waitForText(r.lastFrame, "detail");
102+
await r.press("down");
103+
await r.press("up");
104+
await r.press("return");
105+
await waitForText(r.lastFrame, "agentcore → harness → get → MyHarness-abc123 → json");
106+
r.unmount();
107+
});
108+
98109
test("enter on `endpoints` opens this harness's endpoint list", async () => {
99110
const { core, r } = hubScreen();
100111
core.harness.setListEndpointsResponse({
Lines changed: 32 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
1-
import { useState } from "react";
2-
import { Box, Text, useInput } from "ink";
31
import { useQuery } from "@tanstack/react-query";
42
import { useNavigate, useParams } from "react-router";
53
import type { ScreenProps } from "../../types";
64
import { coreOptsFromCtx } from "../../utils";
7-
import { Spinner } from "../../../components/ui/spinner";
8-
import { Layout } from "../../../components/Layout";
95
import { JsonDetail } from "../../../components/JsonDetail";
10-
import { darkTheme } from "../../../components/ui/_core.js";
11-
import { KeyValueTable } from "../../../components/KeyValueTable.js";
12-
import { Divider } from "../../../components/ui/divider/Divider.js";
13-
14-
const theme = darkTheme;
6+
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";
157

168
// The actions offered for a harness, in menu order. Each routes into the
179
// corresponding flow with the harness preselected.
@@ -52,102 +44,52 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string }
5244
// ARN, execution role, status) above an action selector that jumps into the
5345
// harness's flows (detail JSON, endpoints, versions, invoke, exec). The harness
5446
// ID comes from the `:harnessId` route path value.
55-
export function HarnessGetScreen({ ctx, core }: ScreenProps) {
47+
function useHarnessDetail({ ctx, core }: ScreenProps, harnessId: string | undefined) {
5648
const opts = coreOptsFromCtx(ctx);
57-
const navigate = useNavigate();
58-
const { harnessId } = useParams();
59-
60-
const detail = useQuery({
49+
return useQuery({
6150
queryKey: ["harness", opts.region, harnessId],
6251
queryFn: () => core.harness.getHarness(harnessId!, opts),
6352
enabled: harnessId !== undefined,
6453
});
54+
}
6555

66-
const [index, setIndex] = useState(0);
67-
68-
useInput((input, key) => {
69-
if (key.escape) {
70-
navigate(-1);
71-
return;
72-
}
73-
if (key.upArrow || input === "k") {
74-
setIndex((i) => Math.max(0, i - 1));
75-
return;
76-
}
77-
if (key.downArrow || input == "j") {
78-
setIndex((i) => Math.min(ACTIONS.length - 1, i + 1));
79-
return;
80-
}
81-
if (key.return && harnessId) {
82-
navigate(ACTIONS[index]!.to(harnessId));
83-
}
84-
});
85-
56+
export function HarnessGetScreen(props: ScreenProps) {
57+
const navigate = useNavigate();
58+
const { harnessId } = useParams();
59+
const detail = useHarnessDetail(props, harnessId);
8660
const harness = detail.data?.harness;
87-
const nameWidth = ACTIONS.reduce((m, a) => Math.max(m, a.name.length), 0) + 3;
8861

8962
return (
90-
<Layout
63+
<ResourceDetailScreen
9164
breadcrumb={["agentcore", "harness", "get", harnessId ?? ""]}
92-
keyHints={[
93-
{ key: "↑↓/kj", label: "navigate" },
94-
{ key: "enter", label: "select" },
95-
{ key: "esc", label: "back" },
96-
{ key: "ctl+c", label: "quit" },
97-
]}
98-
>
99-
{detail.isPending ? (
100-
<Spinner label="Loading harness…" />
101-
) : detail.isError ? (
102-
<Text color="red">Error: {(detail.error as Error).message}</Text>
103-
) : (
104-
<Box flexDirection="column">
105-
{/* Summary overlay */}
106-
<Box flexDirection="column" paddingLeft={1}>
107-
<KeyValueTable
108-
items={{
109-
id: harness?.harnessId ?? "",
110-
status: harness?.status ?? "",
111-
version: harness?.harnessVersion?.toString() ?? "0",
112-
arn: harness?.arn ?? "",
113-
}}
114-
/>
115-
</Box>
116-
117-
<Divider />
118-
119-
{/* Action selector */}
120-
<Box flexDirection="column" paddingLeft={1}>
121-
{ACTIONS.map((action, i) => {
122-
const isHl = i === index;
123-
return (
124-
<Box key={action.name}>
125-
<Text color={theme.colors.focus}>{isHl ? "❯ " : " "}</Text>
126-
<Text bold={isHl} color={isHl ? theme.colors.focus : theme.colors.text}>
127-
{action.name.padEnd(nameWidth)}
128-
</Text>
129-
<Text color={theme.colors.muted}>{action.description}</Text>
130-
</Box>
131-
);
132-
})}
133-
</Box>
134-
</Box>
135-
)}
136-
</Layout>
65+
isPending={detail.isPending}
66+
error={detail.isError ? (detail.error as Error) : null}
67+
items={{
68+
id: harness?.harnessId ?? "",
69+
status: harness?.status ?? "",
70+
version: harness?.harnessVersion?.toString() ?? "0",
71+
arn: harness?.arn ?? "",
72+
}}
73+
actions={
74+
harnessId && harness
75+
? ACTIONS.map((action) => ({
76+
name: action.name,
77+
description: action.description,
78+
onSelect: () => navigate(action.to(harnessId)),
79+
}))
80+
: []
81+
}
82+
loadingLabel="Loading harness…"
83+
onRetry={() => void detail.refetch()}
84+
/>
13785
);
13886
}
13987

14088
// HarnessGetJsonScreen renders the harness's full definition as scrollable JSON
14189
// (the hub's "detail" action).
142-
export function HarnessGetJsonScreen({ ctx, core }: ScreenProps) {
143-
const opts = coreOptsFromCtx(ctx);
90+
export function HarnessGetJsonScreen(props: ScreenProps) {
14491
const { harnessId } = useParams();
145-
146-
const detail = useQuery({
147-
queryKey: ["harness", opts.region, harnessId],
148-
queryFn: () => core.harness.getHarness(harnessId!, opts),
149-
enabled: harnessId !== undefined,
150-
});
92+
const detail = useHarnessDetail(props, harnessId);
15193

15294
return (
15395
<JsonDetail
@@ -156,6 +98,7 @@ export function HarnessGetJsonScreen({ ctx, core }: ScreenProps) {
15698
error={detail.isError ? (detail.error as Error) : null}
15799
data={detail.data?.harness}
158100
loadingLabel="Loading harness…"
101+
onRetry={() => void detail.refetch()}
159102
/>
160103
);
161104
}

src/handlers/memory/get/screen.tsx

Lines changed: 30 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { useQuery } from "@tanstack/react-query";
2-
import { Box, Text, useInput } from "ink";
32
import { useNavigate, useParams } from "react-router";
43
import { JsonDetail } from "../../../components/JsonDetail";
5-
import { KeyValueTable } from "../../../components/KeyValueTable.js";
6-
import { Layout } from "../../../components/Layout";
7-
import { darkTheme } from "../../../components/ui/_core.js";
8-
import { Divider } from "../../../components/ui/divider/Divider.js";
9-
import { Spinner } from "../../../components/ui/spinner";
4+
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";
105
import type { ScreenProps } from "../../types";
116
import { coreOptsFromCtx } from "../../utils";
127

@@ -25,64 +20,37 @@ export function MemoryGetScreen(props: ScreenProps) {
2520
const detail = useMemoryDetail(props, memoryId);
2621
const memory = detail.data?.memory;
2722

28-
useInput((input, key) => {
29-
if (key.escape) {
30-
navigate(-1);
31-
return;
32-
}
33-
if (input === "r" && detail.isError) {
34-
void detail.refetch();
35-
return;
36-
}
37-
if (detail.isError || !memory) return;
38-
if (key.return && memoryId) {
39-
navigate(`/agentcore/memory/get/${encodeURIComponent(memoryId)}/json`);
40-
}
41-
});
42-
4323
return (
44-
<Layout
24+
<ResourceDetailScreen
4525
breadcrumb={["agentcore", "memory", "get", memoryId ?? ""]}
46-
keyHints={[
47-
...(!detail.isPending && !detail.isError ? [{ key: "enter", label: "open detail" }] : []),
48-
...(detail.isError ? [{ key: "r", label: "retry" }] : []),
49-
{ key: "esc", label: "back" },
50-
{ key: "ctl+c", label: "quit" },
51-
]}
52-
>
53-
{detail.isPending ? (
54-
<Spinner label="Loading Memory…" />
55-
) : detail.isError ? (
56-
<Text color="red">Error: {(detail.error as Error).message}</Text>
57-
) : (
58-
<Box flexDirection="column">
59-
<Box flexDirection="column" paddingLeft={1}>
60-
<KeyValueTable
61-
items={{
62-
name: memory?.name ?? "",
63-
id: memory?.id ?? "",
64-
status: memory?.status ?? "",
65-
eventExpiryDays: memory?.eventExpiryDuration?.toString() ?? "-",
66-
strategies: memory?.strategies?.length.toString() ?? "0",
67-
updatedAt: memory?.updatedAt?.toISOString() ?? "-",
68-
...(memory?.failureReason ? { failureReason: memory.failureReason } : {}),
69-
arn: memory?.arn ?? "",
70-
}}
71-
/>
72-
</Box>
73-
74-
<Divider />
75-
76-
<Box paddingLeft={1}>
77-
<Text color={darkTheme.colors.focus}></Text>
78-
<Text bold color={darkTheme.colors.focus}>
79-
{"detail".padEnd(9)}
80-
</Text>
81-
<Text color={darkTheme.colors.muted}>show the full JSON definition</Text>
82-
</Box>
83-
</Box>
84-
)}
85-
</Layout>
26+
isPending={detail.isPending}
27+
error={detail.isError ? (detail.error as Error) : null}
28+
items={{
29+
name: memory?.name ?? "",
30+
id: memory?.id ?? "",
31+
status: memory?.status ?? "",
32+
eventExpiryDays: memory?.eventExpiryDuration?.toString() ?? "-",
33+
strategies: memory?.strategies?.length.toString() ?? "0",
34+
updatedAt: memory?.updatedAt?.toISOString() ?? "-",
35+
...(memory?.failureReason ? { failureReason: memory.failureReason } : {}),
36+
arn: memory?.arn ?? "",
37+
}}
38+
actions={
39+
memoryId && memory
40+
? [
41+
{
42+
name: "detail",
43+
description: "show the full JSON definition",
44+
onSelect: () =>
45+
navigate(`/agentcore/memory/get/${encodeURIComponent(memoryId)}/json`),
46+
},
47+
]
48+
: []
49+
}
50+
loadingLabel="Loading Memory…"
51+
onRetry={() => void detail.refetch()}
52+
selectLabel="open detail"
53+
/>
8654
);
8755
}
8856

0 commit comments

Comments
 (0)