feat(project): add interactive remove TUI - #11
Conversation
| import type { ScreenProps } from "../../types"; | ||
| import type { Project } from "../types"; | ||
| // The resource types whose removal is fully specified by a name alone. Nested |
There was a problem hiding this comment.
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.
| interface RemovableType { | ||
| resourceType: SimpleResourceType; | ||
| label: string; | ||
| names: (spec: ProjectSpec) => string[]; |
There was a problem hiding this comment.
this should be called getNamedFromSpec
| const REMOVE_ROOT = "/agentcore/project/remove"; | ||
| // ProjectRemoveScreen removes resources from the current project's spec. It |
| 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, |
There was a problem hiding this comment.
why do we need to use this? can't we use the project in the context?
| { key: "ctl+c", label: "quit" }, | ||
| ]} | ||
| > | ||
| <Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text> |
There was a problem hiding this comment.
yeah remove all of this and support all resources in the remove screen.
| <Layout | ||
| breadcrumb={["agentcore", "project", "remove"]} | ||
| description={`choose a resource to remove from project ${project.name}`} | ||
| keyHints={[ |
There was a problem hiding this comment.
we shouldn't have to respecifcy these, there should be a common component we can use.
| createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }), | ||
| ), | ||
| ); | ||
| // A bare `agentcore project remove` in an interactive session opens the TUI |
There was a problem hiding this comment.
please stop with all these comments. they are unreadable and overly verbose
| io: config.io, | ||
| }); | ||
| const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject); | ||
| const removeProjectDispatch: Handler = { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
can we just say not in a list to simplify this.
Hweinstock
commented
Sep 1, 2026
why does all have resource count of 2? Shouldn't it be the sum? |
| emptyMessage: string; | ||
| } | ||
| // StaticTablePicker is the in-memory counterpart to PaginatedTablePicker: a |
| // 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>>({ |
There was a problem hiding this comment.
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 |
| await Promise.all(temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true }))); | ||
| }); | ||
| async function setup(spec: Record<string, unknown>): Promise<{ |
There was a problem hiding this comment.
can we just create a real create with the handler, and use that.
| // the parent column with (nested types only), and how to read its resources | ||
| // off the project spec. | ||
| interface RemovableType { | ||
| resourceType: string; |
There was a problem hiding this comment.
can we type this stronger than string?
| resourceType: "gateway-target", | ||
| label: "gateway-target", | ||
| parentLabel: "gateway", | ||
| getNamedFromSpec: (s) => |
There was a problem hiding this comment.
why is this getNamedFromSpec? SHould it be getNameFromSpec?
| return <RemoveConfirm project={project} core={core} type={type} resource={resource} />; | ||
| } | ||
| type TypeRow = Record<string, unknown> & { type: string; count: string; value: string }; |
There was a problem hiding this comment.
is there a better name for this?
| { key: "count", header: "count", width: 8, align: "right" }, | ||
| ] satisfies DataTableColumn<TypeRow>[]; | ||
| function TypePicker({ project }: { project: Project }) { |
| type ResourceRow = Record<string, unknown> & { index: string; name: string; parent: string }; | ||
| function ResourcePicker({ project, type }: { project: Project; type: RemovableType }) { |
There was a problem hiding this comment.
whats resource vs type picker?
There was a problem hiding this comment.
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 |
| } | ||
| } | ||
| // createProject scaffolds a real project (one runtime, "hello_world") in a temp |
There was a problem hiding this comment.
this is also a useless comment
| | "payment-manager"; | ||
| /** Every removable resource type, including the nested ones. */ | ||
| type RemovableTypeId = |
There was a problem hiding this comment.
these are not Ids. Its RemoveableResourceType
| import type { Project, RemoveResourceInput } from "../types"; | ||
| /** The resource types removed by name alone (RemoveResourceInput's first branch). */ | ||
| type NameOnlyType = |
There was a problem hiding this comment.
lets call these root level resources. avoid the type language.
| NameOnlyType | "gateway-target" | "gateway-connector" | "policy" | "payment-connector"; | ||
| /** A specific resource in the project, paired with the input that removes it. */ | ||
| interface RemovableResource { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
is there a way to use a router screen for this? If its awkward don't force it, but it seems very similar.
| return <RemoveConfirm project={project} core={core} category={category} resource={resource} />; | ||
| } | ||
| type CategoryRow = Record<string, unknown> & { resource: string; count: string; value: string }; |
| { key: "count", header: "count", width: 8, align: "right" }, | ||
| ] satisfies DataTableColumn<CategoryRow>[]; | ||
| function CategoryPicker({ project }: { project: Project }) { |
There was a problem hiding this comment.
what is a category? is this a resource type? lets be consistent with our language?
| type ResourceRow = Record<string, unknown> & { index: string; name: string; parent: string }; | ||
| function ResourcePicker({ project, category }: { project: Project; category: ResourceCategory }) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
just remove this comment altogther.
| /** 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. */ |
There was a problem hiding this comment.
is this the name of the owning resource? lets make this clear by calling it parentName. lets also adjust the comment.
| input: RemoveResourceInput; | ||
| }; | ||
| type RemovableResourceTypeInfo = { |
There was a problem hiding this comment.
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.
| const info = REMOVABLE_RESOURCE_TYPES.find((entry) => entry.resourceType === resourceType); | ||
| if (!info) { | ||
| return <Navigate to={REMOVE_ROOT} replace />; |
There was a problem hiding this comment.
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?
| return <Navigate to={REMOVE_ROOT} replace />; | ||
| } | ||
| if (index === undefined) { |
There was a problem hiding this comment.
what is this the index of? can we name it explicitly.
| return <ResourcePicker project={project} info={info} />; | ||
| } | ||
| const resource = info.list(project.spec)[Number(index)]; |
There was a problem hiding this comment.
this feels convoluted, we're looking on the index in one array then using that to extract from another? Is there a simpler way?
| createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }), | ||
| ), | ||
| ); | ||
| project.handler( |
There was a problem hiding this comment.
q, why was this re-ordered? If its not necessary, avoid noise in the change.
| input: RemoveResourceInput; | ||
| }; | ||
| /** How one removable resource type is listed and rendered as a table. */ |
There was a problem hiding this comment.
rendered in the table. a resource type is not rendered as a table, that makes no sense.
| }; | ||
| /** How one removable resource type is listed and rendered as a table. */ | ||
| type RemovableResourceTable = { |
There was a problem hiding this comment.
RemovableResourceTableConfig maybe?
| return <RemoveAllConfirm project={project} core={core} />; | ||
| } | ||
| // An unrecognized resourceType only reaches here from a hand-edited URL; the |
There was a problem hiding this comment.
more concise. ex. "fallback to resource type selection screen on unrecognized type"
| return <ResourceTypePicker project={project} />; | ||
| } | ||
| // resourceIndex points into the same list() the picker rendered; a specific |
| const { resourceType, resourceIndex } = useParams(); | ||
| const navigate = useNavigate(); | ||
| // Resolve from the working directory so the list reflects removals made this |
| }); | ||
| const project = resolved.data ?? pinned ?? undefined; | ||
| // The spinner/error/no-project states render no table, so handle esc here; |
There was a problem hiding this comment.
change comment to // explicitly wire esc for the no project found case.
| } | ||
| // Fall back to the resource-type selection screen on an unrecognized type. | ||
| const table = RESOURCE_TABLES.find((entry) => entry.resourceType === resourceType); |
There was a problem hiding this comment.
could this be combined with the case on line 178?
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 existingagentcore harness deleteTUI and reuses the shared components (Layout,DataTable,ConfirmAction).Flow:
alloption.runtime→ every runtime;policy→ every policy, with a column naming its parent engine.enteron a resource prompts for a default-No confirmation, then removes it (editsagentcore.json; deployed infra is untouched until the next deploy).escon the per-type list returns to the resource-type list;escon the resource-type list returns to the project menu.Bare
agentcore project removein an interactive TTY opens the TUI; passing a resource/flag or--json, or running non-interactively, keeps the existing headless behavior (including theallconfirmation).Coverage
Every resource type the headless
removehandles is available in the TUI, including the nested ones. Nested resources are listed flat with a parent column and removed with their parent:{ resourceType, name }{ resourceType: "gateway-target", gatewayName, name }{ resourceType: "policy", engineName, name }{ resourceType: "payment-connector", managerName, name }Both picker levels render
Layout+DataTabledirectly with a shared key-hints constant. Theallrow 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 nestedpolicyandpayment-connectorlists (name first, then the parent), an end-to-end runtime removal returning to the refreshed selector, and the remove-all confirmation:Spec
Screenshots
Captured by rendering each screen through the app's real
Rootwithink-testing-libraryand converting the ANSI frames to PNG withcharmbracelet/freeze.1. Resource-type list (every resource in the project +

all)2. Per-type list (

runtime→ every runtime)3. Confirmation (default-No)

4. Removed (success panel)

5. Nested list with parent column (

policy→ every policy, with itsengine)6. Nested confirmation (parent engine shown in the summary)

7. Remove-all confirmation

8. All removed (success panel)

Verification
All run on this branch (base
refactor):bun run typecheck— clean.oxlint+prettier --check— clean (husky pre-commit enforces both).bun test— 2591 pass / 0 fail across 188 files. Theproject removescreen tests drive the realFsProjectManageragainst a temp project on disk (no mocks) and assert the spec is actually mutated, including a nested policy removal.bun run compile:linux-x64):Headless removal is spec-accurate:
Bare invocation launches the TUI (captured under a pty):
Reproduce
Updated after review: the TUI covers all resource types (nested resources listed with a parent column), reads the project from context, and the
allrow 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 realcreate/addResourceflow.