Skip to content

feat(project): add interactive remove TUI - #11

Draft
Hweinstock wants to merge 21 commits into
refactorfrom
feat/project-remove-tui
Draft

feat(project): add interactive remove TUI#11
Hweinstock wants to merge 21 commits into
refactorfrom
feat/project-remove-tui

Conversation

@Hweinstock

@Hweinstock Hweinstock commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an interactive TUI for agentcore project remove, which previously had no screen (it was listed in the project menu but routed to the "not implemented" screen). The flow mirrors the existing agentcore harness delete TUI and reuses the shared components (Layout, DataTable, ConfirmAction).

Flow:

  1. Resource-type list — every resource type the project holds (with counts) plus an all option.
  2. Per-type resource list — e.g. runtime → every runtime; policy → every policy, with a column naming its parent engine.
  3. Confirmationenter on a resource prompts for a default-No confirmation, then removes it (edits agentcore.json; deployed infra is untouched until the next deploy).
  4. esc on the per-type list returns to the resource-type list; esc on the resource-type list returns to the project menu.

Bare agentcore project remove in an interactive TTY opens the TUI; passing a resource/flag or --json, or running non-interactively, keeps the existing headless behavior (including the all confirmation).

Coverage

Every resource type the headless remove handles is available in the TUI, including the nested ones. Nested resources are listed flat with a parent column and removed with their parent:

Type Parent column Removed via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager { resourceType, name }
gateway-target / gateway-connector gateway { resourceType: "gateway-target", gatewayName, name }
policy engine { resourceType: "policy", engineName, name }
payment-connector manager { resourceType: "payment-connector", managerName, name }

Both picker levels render Layout + DataTable directly with a shared key-hints constant. The all row shows the sum of every resource count.

Demo

A project with a few of each resource, walking through the full flow — the resource-type list (with counts and all), drilling into runtime, the nested policy and payment-connector lists (name first, then the parent), an end-to-end runtime removal returning to the refreshed selector, and the remove-all confirmation:

remove TUI demo

Spec

Problem

On the refactor branch, the remove command does not yet have a TUI.

Definition of Done

  • running agentcore project remove should launch the TUI. The screen should show all resources the customer may remove, and an all option.
  • clicking individual resources should load the options for deleting (i.e. runtime → all runtimes in the project listed, gateway → all gateways in the project listed), then pressing enter on the resource should prompt for confirmation, then remove. Follow the example set by the harness delete.
  • pressing esc on the list view should bring back to the resource list.

Notes

  • the PR should include screenshots of every single screen implemented.
  • The implementation should re-use common components that are available.

Screenshots

Captured by rendering each screen through the app's real Root with ink-testing-library and converting the ANSI frames to PNG with charmbracelet/freeze.

1. Resource-type list (every resource in the project + all)
resource-type list

2. Per-type list (runtime → every runtime)
runtime list

3. Confirmation (default-No)
confirm runtime

4. Removed (success panel)
runtime removed

5. Nested list with parent column (policy → every policy, with its engine)
policy list

6. Nested confirmation (parent engine shown in the summary)
confirm policy

7. Remove-all confirmation
confirm all

8. All removed (success panel)
all removed

Verification

All run on this branch (base refactor):

  • Typecheck: bun run typecheck — clean.
  • Lint / format: oxlint + prettier --check — clean (husky pre-commit enforces both).
  • Unit/behavior tests: bun test2591 pass / 0 fail across 188 files. The project remove screen tests drive the real FsProjectManager against a temp project on disk (no mocks) and assert the spec is actually mutated, including a nested policy removal.
  • Compiled binary (bun run compile:linux-x64):

Headless removal is spec-accurate:

$ agentcore project remove runtime --name checkout
removed runtime with name 'checkout' from project
# agentcore.json afterwards: "runtimes": [], harness "support" preserved

Bare invocation launches the TUI (captured under a pty):

$ agentcore project remove
 choose a resource to remove from project orders
❯ runtime                                          2
  harness                                          1
  all

Reproduce

bun install
bun run typecheck
bun test src/handlers/project/remove         # the behavior tests
bun run compile:linux-x64                     # or your platform's compile:* target

# In any AgentCore project directory (contains agentcore/agentcore.json):
./dist/bin/agentcore-linux-x64 project remove                      # launches the TUI
./dist/bin/agentcore-linux-x64 project remove runtime --name NAME  # headless path (unchanged)

Updated after review: the TUI covers all resource types (nested resources listed with a parent column), reads the project from context, and the all row sums every resource count. Types/pickers were renamed for clarity, the picker markup inlined with a shared key-hints constant, and the screen tests now scaffold projects through the real create/addResource flow.

Comment thread src/handlers/project/remove/screen.tsx Outdated
import type { ScreenProps } from "../../types";
import type { Project } from "../types";

// The resource types whose removal is fully specified by a name alone. Nested

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets address this gap here. For example, for gateway targets we list all of them in the TUI, and then have a column with the parent resource name (gateway). So we need all gateway targets with another columns for which gateway they fall under. we can do the same for payments.

Comment thread src/handlers/project/remove/screen.tsx Outdated
interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be called getNamedFromSpec

Comment thread src/handlers/project/remove/screen.tsx Outdated

const REMOVE_ROOT = "/agentcore/project/remove";

// ProjectRemoveScreen removes resources from the current project's spec. It

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this comment.

Comment thread src/handlers/project/remove/screen.tsx Outdated
const query = useQuery({
queryKey: ["project", "remove", cwd],
// react-query rejects an undefined resolution; normalize "no project" to null.
queryFn: async () => (await core.projectManager.resolve({ filePath: cwd })) ?? null,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to use this? can't we use the project in the context?

Comment thread src/handlers/project/remove/screen.tsx Outdated
{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah remove all of this and support all resources in the remove screen.

Comment thread src/handlers/project/remove/screen.tsx Outdated
<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't have to respecifcy these, there should be a common component we can use.

Comment thread src/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
// A bare `agentcore project remove` in an interactive session opens the TUI

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please stop with all these comments. they are unreadable and overly verbose

Comment thread src/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what you are doing here, but don't do this. .follow existing patterns and keep it simple.

// painting, so it and this screen both render empty. `create`, `invoke`, and
// `remove` are excluded because all three have real screens.
test.each(
projectSubcommands().filter(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we just say not in a list to simplify this.

@Hweinstock

Copy link
Copy Markdown
Owner Author

why does all have resource count of 2? Shouldn't it be the sum?

Comment thread src/components/StaticTablePicker.tsx Outdated
emptyMessage: string;
}

// StaticTablePicker is the in-memory counterpart to PaginatedTablePicker: a

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment is useless.

Comment thread src/components/StaticTablePicker.tsx Outdated
// filterable, keyboard-navigable table over caller-supplied rows, wrapped in
// the standard Layout and key hints. Use it when the rows are already resolved
// (e.g. read from a project spec) rather than paged from a service.
export function StaticTablePicker<TRow extends Record<string, unknown>>({

@Hweinstock Hweinstock Sep 1, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused what value this provides? it looks like a direct proxy to Layout. Would it make sense to inline?

TestCoreClient,
} from "../../../testing";

// Behavior tests for the project-remove flow. TestCoreClient carries a real

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useless comment

await Promise.all(temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true })));
});

async function setup(spec: Record<string, unknown>): Promise<{

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we just create a real create with the handler, and use that.

Comment thread src/handlers/project/remove/screen.tsx Outdated
// the parent column with (nested types only), and how to read its resources
// off the project spec.
interface RemovableType {
resourceType: string;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we type this stronger than string?

Comment thread src/handlers/project/remove/screen.tsx Outdated
resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this getNamedFromSpec? SHould it be getNameFromSpec?

Comment thread src/handlers/project/remove/screen.tsx Outdated
return <RemoveConfirm project={project} core={core} type={type} resource={resource} />;
}

type TypeRow = Record<string, unknown> & { type: string; count: string; value: string };

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a better name for this?

Comment thread src/handlers/project/remove/screen.tsx Outdated
{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

function TypePicker({ project }: { project: Project }) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type is ambigious

Comment thread src/handlers/project/remove/screen.tsx Outdated

type ResourceRow = Record<string, unknown> & { index: string; name: string; parent: string };

function ResourcePicker({ project, type }: { project: Project; type: RemovableType }) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats resource vs type picker?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment is way too verbose, lets make it more concise, and lets simplify

TestCoreClient,
} from "../../../testing";

// TestCoreClient carries a real FsProjectManager, so the project is scaffolded

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this comment

}
}

// createProject scaffolds a real project (one runtime, "hello_world") in a temp

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is also a useless comment

Comment thread src/handlers/project/remove/screen.tsx Outdated
| "payment-manager";

/** Every removable resource type, including the nested ones. */
type RemovableTypeId =

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are not Ids. Its RemoveableResourceType

Comment thread src/handlers/project/remove/screen.tsx Outdated
import type { Project, RemoveResourceInput } from "../types";

/** The resource types removed by name alone (RemoveResourceInput's first branch). */
type NameOnlyType =

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets call these root level resources. avoid the type language.

Comment thread src/handlers/project/remove/screen.tsx Outdated
NameOnlyType | "gateway-target" | "gateway-connector" | "policy" | "payment-connector";

/** A specific resource in the project, paired with the input that removes it. */
interface RemovableResource {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a type, interface is reserved for concepts that may take multiple implementations.

{ key: "ctl+c", label: "quit" },
];

export function ProjectRemoveScreen({ ctx, core }: ScreenProps) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to use a router screen for this? If its awkward don't force it, but it seems very similar.

Comment thread src/handlers/project/remove/screen.tsx Outdated
return <RemoveConfirm project={project} core={core} category={category} resource={resource} />;
}

type CategoryRow = Record<string, unknown> & { resource: string; count: string; value: string };

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this name is ambiguous

Comment thread src/handlers/project/remove/screen.tsx Outdated
{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

function CategoryPicker({ project }: { project: Project }) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is a category? is this a resource type? lets be consistent with our language?

Comment thread src/handlers/project/remove/screen.tsx Outdated

type ResourceRow = Record<string, unknown> & { index: string; name: string; parent: string };

function ResourcePicker({ project, category }: { project: Project; category: ResourceCategory }) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats the difference between this and the category picker?

// painting, so it and this screen both render empty. `create` and `invoke`
// are excluded because both have real screens.
test.each(projectSubcommands().filter((command) => command !== "create" && command !== "invoke"))(
// renderTuiAt (not renderScreen) so the NotImplementedError rejection is

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just remove this comment altogther.

Comment thread src/handlers/project/remove/screen.tsx Outdated
/** A specific resource in the project, paired with the input that removes it. */
type RemovableResource = {
name: string;
/** The owning gateway/engine/manager, for nested resources. */

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this the name of the owning resource? lets make this clear by calling it parentName. lets also adjust the comment.

Comment thread src/handlers/project/remove/screen.tsx Outdated
input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this name feels off. This is really the data that determines how the data is rendered to the table. Should we call it RemovableResourceTableData or something? See if there is a better name that expresses this idea clearly.

Comment thread src/handlers/project/remove/screen.tsx Outdated

const info = REMOVABLE_RESOURCE_TYPES.find((entry) => entry.resourceType === resourceType);
if (!info) {
return <Navigate to={REMOVE_ROOT} replace />;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this case for? if the input resource isn't one of our known types go to the root? Is there a way to make this impossible with types, rather than having an explicit case?

Comment thread src/handlers/project/remove/screen.tsx Outdated
return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this the index of? can we name it explicitly.

Comment thread src/handlers/project/remove/screen.tsx Outdated
return <ResourcePicker project={project} info={info} />;
}

const resource = info.list(project.spec)[Number(index)];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels convoluted, we're looking on the index in one array then using that to extract from another? Is there a simpler way?

Comment thread src/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q, why was this re-ordered? If its not necessary, avoid noise in the change.

Comment thread src/handlers/project/remove/screen.tsx Outdated
input: RemoveResourceInput;
};

/** How one removable resource type is listed and rendered as a table. */

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rendered in the table. a resource type is not rendered as a table, that makes no sense.

Comment thread src/handlers/project/remove/screen.tsx Outdated
};

/** How one removable resource type is listed and rendered as a table. */
type RemovableResourceTable = {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RemovableResourceTableConfig maybe?

Comment thread src/handlers/project/remove/screen.tsx Outdated
return <RemoveAllConfirm project={project} core={core} />;
}

// An unrecognized resourceType only reaches here from a hand-edited URL; the

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more concise. ex. "fallback to resource type selection screen on unrecognized type"

Comment thread src/handlers/project/remove/screen.tsx Outdated
return <ResourceTypePicker project={project} />;
}

// resourceIndex points into the same list() the picker rendered; a specific

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this comment.

Comment thread src/handlers/project/remove/screen.tsx Outdated
const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

// Resolve from the working directory so the list reflects removals made this

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this comment

Comment thread src/handlers/project/remove/screen.tsx Outdated
});
const project = resolved.data ?? pinned ?? undefined;

// The spinner/error/no-project states render no table, so handle esc here;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change comment to // explicitly wire esc for the no project found case.

Comment thread src/handlers/project/remove/screen.tsx Outdated
}

// Fall back to the resource-type selection screen on an unrecognized type.
const table = RESOURCE_TABLES.find((entry) => entry.resourceType === resourceType);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this be combined with the case on line 178?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant