Skip to content
Draft
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ responses. `agentcore` wraps all of that behind one ergonomic tool.
Commands with operation flags run headlessly. Bare Harness, Runtime, Memory,
Identity, and Gateway branches and leaves open their interactive flows, as does
a bare `project create` in a terminal (any flag, `--json`, or a non-TTY stays
headless).
headless). A bare `project status` opens a Linked Resources view that groups
the project's resources by agent and forwards to each deployed resource's
detail page.

```
agentcore # interactive TUI
Expand Down Expand Up @@ -133,7 +135,7 @@ agentcore # interactive TUI
│ ├── invoke # invoke a deployed project resource
│ │ ├── runtime # use the existing Runtime invoke experience
│ │ └── harness # use the existing Harness invoke experience
│ ├── status # inspect deployed project resources
│ ├── status # inspect deployed project resources (TUI when run bare)
│ └── build # synthesize the project's CloudFormation templates
└── config # read/write global config values
```
Expand Down
5 changes: 5 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import { BuildProjectScreen } from "../handlers/project/build/screen.tsx";
import { DeployProjectScreen } from "../handlers/project/deploy/screen.tsx";
import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx";
import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx";
import { ProjectStatusScreen } from "../handlers/project/status/screen.tsx";
import { HelpScreen, RootScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand Down Expand Up @@ -772,6 +773,10 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/project/create"
element={<ProjectCreateScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/project/status"
element={<ProjectStatusScreen ctx={ctx} core={core} />}
/>
{/* Every known command without a screen of its own: a group opens its
menu and a leaf its interactive help. Unknown routes retain the
help-and-exit fallback. */}
Expand Down
17 changes: 16 additions & 1 deletion src/components/ui/tree-view/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { InkUITheme } from "../_core.js";
export interface TreeNode<T = unknown> {
id: string;
label: string;
/** Muted text rendered after the label (e.g. a status or description). */
annotation?: string;
children?: TreeNode<T>[];
icon?: string;
defaultExpanded?: boolean;
Expand All @@ -23,6 +25,11 @@ export interface TreeViewProps<T = unknown> {
leafIcon?: string;
branchIcon?: string;
branchOpenIcon?: string;
/**
* Mark the focused row with a "❯ " prefix in the focus color instead of
* inverse video, matching the selection style of DataTable and the menus.
*/
focusMarker?: boolean;
focus?: boolean;
theme?: InkUITheme;
}
Expand Down Expand Up @@ -62,6 +69,7 @@ export function TreeView<T = unknown>({
leafIcon = "📄",
branchIcon = "📁",
branchOpenIcon = "📂",
focusMarker = false,
focus = true,
theme = darkTheme,
}: TreeViewProps<T>): React.ReactElement {
Expand Down Expand Up @@ -149,6 +157,7 @@ export function TreeView<T = unknown>({

return (
<Box key={node.id} flexDirection="row">
{focusMarker && <Text color={theme.colors.focus}>{isFocused ? "❯ " : " "}</Text>}
{guides && depth > 0 && <Text color={theme.colors.border}>{guidePrefix}</Text>}
<Text
color={
Expand All @@ -160,14 +169,20 @@ export function TreeView<T = unknown>({
}
bold={isFocused}
dimColor={node.disabled}
inverse={isFocused}
inverse={isFocused && !focusMarker}
>
<Text color={hasBranch ? theme.colors.primary : theme.colors.muted}>
{branchChar}
</Text>
{showIcons ? ` ${icon} ` : " "}
{node.label}
</Text>
{node.annotation !== undefined && (
<Text color={theme.colors.muted} dimColor={node.disabled}>
{" "}
{node.annotation}
</Text>
)}
</Box>
);
})}
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/gateway/get/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { useNavigate, useParams } from "react-router";
import { JsonDetail } from "../../../components/JsonDetail";
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";
import type { ScreenProps } from "../../types";
import { coreOptsFromCtx } from "../../utils";
import { useCoreOpts } from "../../utils";

function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefined) {
const opts = coreOptsFromCtx(ctx);
const opts = useCoreOpts(ctx);
return useQuery({
queryKey: ["gateway", opts.region, gatewayId],
queryFn: () => core.gateway.getGateway(gatewayId!, opts),
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/gateway/target/get/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
import { JsonDetail } from "../../../../components/JsonDetail";
import type { ScreenProps } from "../../../types";
import { coreOptsFromCtx } from "../../../utils";
import { useCoreOpts } from "../../../utils";

export function GatewayTargetGetScreen(props: ScreenProps) {
const { gatewayId, targetId } = useParams();
const opts = coreOptsFromCtx(props.ctx);
const opts = useCoreOpts(props.ctx);
const detail = useQuery({
queryKey: ["gateway-target", opts.region, gatewayId, targetId],
queryFn: () => props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts),
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/harness/get/screen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import type { ScreenProps } from "../../types";
import { coreOptsFromCtx } from "../../utils";
import { useCoreOpts } from "../../utils";
import { JsonDetail } from "../../../components/JsonDetail";
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";

Expand Down Expand Up @@ -45,7 +45,7 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string }
// harness's flows (detail JSON, endpoints, versions, invoke, exec). The harness
// ID comes from the `:harnessId` route path value.
function useHarnessDetail({ ctx, core }: ScreenProps, harnessId: string | undefined) {
const opts = coreOptsFromCtx(ctx);
const opts = useCoreOpts(ctx);
return useQuery({
queryKey: ["harness", opts.region, harnessId],
queryFn: () => core.harness.getHarness(harnessId!, opts),
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/memory/get/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router";
import { JsonDetail } from "../../../components/JsonDetail";
import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen";
import type { ScreenProps } from "../../types";
import { coreOptsFromCtx } from "../../utils";
import { useCoreOpts } from "../../utils";

const ACTIONS = [
{
Expand Down Expand Up @@ -34,7 +34,7 @@ const ACTIONS = [
] as const;

function useMemoryDetail({ ctx, core }: ScreenProps, memoryId: string | undefined) {
const opts = coreOptsFromCtx(ctx);
const opts = useCoreOpts(ctx);
return useQuery({
queryKey: ["memory", opts.region, memoryId, "full"],
queryFn: () => core.memory.getMemory(memoryId!, "full", opts),
Expand Down
26 changes: 21 additions & 5 deletions src/handlers/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
"invoke",
"build",
"deploy",
"status",
);

// Without a default, a bare `agentcore project` falls back to Commander's help
Expand Down Expand Up @@ -99,11 +100,26 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router
),
);
project.handler(createProjectInvokeHandler(core, io));
project.handler(
withProject({ projectManager: config.projectManager })(
createStatusProjectHandler({ projectManager: config.projectManager }),
),
);
// A bare `agentcore project status` in an interactive session opens the TUI
// linked-resources screen; any user-supplied flag, --json, or a non-TTY
// invocation keeps the headless JSON report (same dispatch shape as create).
// withProject stays outermost so the not-found guidance outside a project is
// the CLI's own, and the resolved project seeds the screen via ProjectKey.
const statusProject = createStatusProjectHandler({ projectManager: config.projectManager });
const statusProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(statusProject);
const statusProjectDispatch: Handler = {
name: () => statusProject.name(),
description: () => statusProject.description(),
flags: () => statusProject.flags(),
arguments: () => statusProject.arguments(),
doesSupportTui: () => statusProject.doesSupportTui(),
children: () => statusProject.children(),
handle: (ctx, flags, args) =>
isInteractive()
? statusProjectWithTui.handle(ctx, flags, args)
: statusProject.handle(ctx, flags, args),
};
project.handler(withProject({ projectManager: config.projectManager })(statusProjectDispatch));
// withProject wraps only the commands that require an existing project, so
// `create` (which refuses to nest inside one) stays unaffected.
project.handler(
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/project/project.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe("project menu: command-line-only subcommands", () => {
const r = renderScreen("/agentcore/project");

await waitForText(r.lastFrame, "command line only");
const withScreens = ["create", "deploy", "invoke", "build"];
const withScreens = ["create", "deploy", "invoke", "build", "status"];
const { screens, cliOnly } = menuEntries(r.lastFrame()!);
expect(screens.toSorted()).toEqual(withScreens.toSorted());
expect(cliOnly.toSorted()).toEqual(
Expand Down
63 changes: 61 additions & 2 deletions src/handlers/project/status/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
TestCoreClient,
TestGlobalConfigAccessor,
testIO,
ttyTestIO,
waitFor,
} from "../../../testing";
import type { ProjectBackend } from "../../../core/project";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
Expand Down Expand Up @@ -45,8 +47,7 @@ function fakeBackend(deployed: ResolvedProjectResource[]) {
return { targets, backend };
}

function testStatusCommand(deployed: ResolvedProjectResource[] = []) {
const io = testIO();
function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO()) {
const fake = fakeBackend(deployed);
const root = createRootHandler(new TestCoreClient({ backends: { CDK: fake.backend } }), {
io: io.io,
Expand Down Expand Up @@ -232,3 +233,61 @@ describe("project status handler", () => {
);
});
});

describe("project status dispatch", () => {
// The bare-invocation tests above run without a TTY and assert the exact
// JSON envelope, which pins the non-TTY headless path; these cover how a TTY
// changes (and does not change) the dispatch.
test("bare status in a TTY session opens the TUI instead of printing JSON", async () => {
const tty = ttyTestIO();
const subject = testStatusCommand([HARNESS_ROW], tty.streams);
await inProject(subject);

// outcome never rejects, so a mid-pump failure cannot trip bun's
// unhandled-rejection detection before the final assertion.
const outcome = subject.run().then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error }),
);
let settled = false;
void outcome.finally(() => {
settled = true;
});

// The screen never finishes on its own; Ctrl+C (re-sent until the app
// reacts) closes it and resolves the route cleanly.
await waitFor(
() => {
if (!settled) tty.stdin.write("\x03");
return settled;
},
5000,
150,
);
expect(await outcome).toEqual({ ok: true });
expect(subject.io.stdout()).not.toContain('"projectName"');
}, 10000);

test("an explicitly passed --target stays headless even in a TTY", async () => {
const subject = testStatusCommand([HARNESS_ROW], ttyTestIO().streams);
await inProject(subject);

await subject.run(["--target", "default"]);

expect(subject.json()).toMatchObject({ projectName: "orders", target: "default" });
});

test("--json stays headless even in a TTY", async () => {
const subject = testStatusCommand([HARNESS_ROW], ttyTestIO().streams);
await inProject(subject);

await subject.run(["--json"]);

expect(subject.json()).toEqual({
projectName: "orders",
target: "default",
region: "us-east-1",
resources: [HARNESS_ROW],
});
});
});
Loading
Loading