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

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

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

Conversation

@Hweinstock

@HweinstockHweinstock 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:

TypeParent columnRemoved via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager{ resourceType, name }
gateway-target / gateway-connectorgateway{ resourceType: "gateway-target", gatewayName, name }
policyengine{ resourceType: "policy", engineName, name }
payment-connectormanager{ 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.

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
OwnerAuthor

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.

interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
OwnerAuthor

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


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

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

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
OwnerAuthor

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?

{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
OwnerAuthor

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.

<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

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

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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>>({

@HweinstockHweinstockSep 1, 2026

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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.

// 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
OwnerAuthor

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?

resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
OwnerAuthor

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?

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
OwnerAuthor

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?

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

type is ambigious


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

| "payment-manager";

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

Copy link
Copy Markdown
OwnerAuthor

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

import type { Project, RemoveResourceInput } from "../types";

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

Copy link
Copy Markdown
OwnerAuthor

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.

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
OwnerAuthor

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
OwnerAuthor

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.

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
OwnerAuthor

Choose a reason for hiding this comment

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

this name is ambiguous

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

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?


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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.

/** 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
OwnerAuthor

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.

input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
OwnerAuthor

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.


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

Copy link
Copy Markdown
OwnerAuthor

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?

return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
OwnerAuthor

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.

return <ResourcePicker project={project} info={info} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
OwnerAuthor

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.

input: RemoveResourceInput;
};

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

Copy link
Copy Markdown
OwnerAuthor

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.

};

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

RemovableResourceTableConfig maybe?

return <RemoveAllConfirm project={project} core={core} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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"

return <ResourceTypePicker project={project} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment

});
const project = resolved.data ?? pinned ?? undefined;

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

Copy link
Copy Markdown
OwnerAuthor

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.

}

// 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
OwnerAuthor

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 freeto 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

@Hweinstock
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

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

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

Conversation

@Hweinstock

@HweinstockHweinstock 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:

TypeParent columnRemoved via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager{ resourceType, name }
gateway-target / gateway-connectorgateway{ resourceType: "gateway-target", gatewayName, name }
policyengine{ resourceType: "policy", engineName, name }
payment-connectormanager{ 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.

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
OwnerAuthor

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.

interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
OwnerAuthor

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


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

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

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
OwnerAuthor

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?

{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
OwnerAuthor

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.

<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

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

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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>>({

@HweinstockHweinstockSep 1, 2026

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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.

// 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
OwnerAuthor

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?

resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
OwnerAuthor

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?

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
OwnerAuthor

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?

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

type is ambigious


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

| "payment-manager";

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

Copy link
Copy Markdown
OwnerAuthor

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

import type { Project, RemoveResourceInput } from "../types";

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

Copy link
Copy Markdown
OwnerAuthor

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.

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
OwnerAuthor

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
OwnerAuthor

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.

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
OwnerAuthor

Choose a reason for hiding this comment

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

this name is ambiguous

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

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?


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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.

/** 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
OwnerAuthor

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.

input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
OwnerAuthor

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.


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

Copy link
Copy Markdown
OwnerAuthor

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?

return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
OwnerAuthor

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.

return <ResourcePicker project={project} info={info} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
OwnerAuthor

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.

input: RemoveResourceInput;
};

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

Copy link
Copy Markdown
OwnerAuthor

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.

};

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

RemovableResourceTableConfig maybe?

return <RemoveAllConfirm project={project} core={core} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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"

return <ResourceTypePicker project={project} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment

});
const project = resolved.data ?? pinned ?? undefined;

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

Copy link
Copy Markdown
OwnerAuthor

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.

}

// 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
OwnerAuthor

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 freeto 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

@Hweinstock
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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

Conversation

@Hweinstock

@HweinstockHweinstock 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:

TypeParent columnRemoved via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager{ resourceType, name }
gateway-target / gateway-connectorgateway{ resourceType: "gateway-target", gatewayName, name }
policyengine{ resourceType: "policy", engineName, name }
payment-connectormanager{ 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.

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
OwnerAuthor

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.

interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
OwnerAuthor

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


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

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

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
OwnerAuthor

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?

{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
OwnerAuthor

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.

<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

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

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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>>({

@HweinstockHweinstockSep 1, 2026

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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.

// 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
OwnerAuthor

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?

resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
OwnerAuthor

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?

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
OwnerAuthor

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?

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

type is ambigious


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

| "payment-manager";

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

Copy link
Copy Markdown
OwnerAuthor

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

import type { Project, RemoveResourceInput } from "../types";

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

Copy link
Copy Markdown
OwnerAuthor

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.

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
OwnerAuthor

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
OwnerAuthor

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.

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
OwnerAuthor

Choose a reason for hiding this comment

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

this name is ambiguous

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

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?


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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.

/** 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
OwnerAuthor

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.

input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
OwnerAuthor

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.


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

Copy link
Copy Markdown
OwnerAuthor

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?

return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
OwnerAuthor

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.

return <ResourcePicker project={project} info={info} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
OwnerAuthor

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.

input: RemoveResourceInput;
};

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

Copy link
Copy Markdown
OwnerAuthor

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.

};

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

RemovableResourceTableConfig maybe?

return <RemoveAllConfirm project={project} core={core} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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"

return <ResourceTypePicker project={project} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment

});
const project = resolved.data ?? pinned ?? undefined;

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

Copy link
Copy Markdown
OwnerAuthor

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.

}

// 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
OwnerAuthor

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 freeto 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

@Hweinstock
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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

Conversation

@Hweinstock

@HweinstockHweinstock 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:

TypeParent columnRemoved via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager{ resourceType, name }
gateway-target / gateway-connectorgateway{ resourceType: "gateway-target", gatewayName, name }
policyengine{ resourceType: "policy", engineName, name }
payment-connectormanager{ 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.

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
OwnerAuthor

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.

interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
OwnerAuthor

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


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

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

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
OwnerAuthor

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?

{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
OwnerAuthor

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.

<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

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

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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>>({

@HweinstockHweinstockSep 1, 2026

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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.

// 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
OwnerAuthor

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?

resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
OwnerAuthor

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?

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
OwnerAuthor

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?

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

type is ambigious


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

| "payment-manager";

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

Copy link
Copy Markdown
OwnerAuthor

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

import type { Project, RemoveResourceInput } from "../types";

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

Copy link
Copy Markdown
OwnerAuthor

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.

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
OwnerAuthor

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
OwnerAuthor

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.

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
OwnerAuthor

Choose a reason for hiding this comment

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

this name is ambiguous

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

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?


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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.

/** 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
OwnerAuthor

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.

input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
OwnerAuthor

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.


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

Copy link
Copy Markdown
OwnerAuthor

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?

return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
OwnerAuthor

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.

return <ResourcePicker project={project} info={info} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
OwnerAuthor

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.

input: RemoveResourceInput;
};

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

Copy link
Copy Markdown
OwnerAuthor

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.

};

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

RemovableResourceTableConfig maybe?

return <RemoveAllConfirm project={project} core={core} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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"

return <ResourceTypePicker project={project} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment

});
const project = resolved.data ?? pinned ?? undefined;

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

Copy link
Copy Markdown
OwnerAuthor

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.

}

// 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
OwnerAuthor

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 freeto 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

@Hweinstock
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

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

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

Conversation

@Hweinstock

@HweinstockHweinstock 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:

TypeParent columnRemoved via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager{ resourceType, name }
gateway-target / gateway-connectorgateway{ resourceType: "gateway-target", gatewayName, name }
policyengine{ resourceType: "policy", engineName, name }
payment-connectormanager{ 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.

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
OwnerAuthor

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.

interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
OwnerAuthor

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


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

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

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
OwnerAuthor

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?

{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
OwnerAuthor

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.

<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

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

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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>>({

@HweinstockHweinstockSep 1, 2026

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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.

// 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
OwnerAuthor

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?

resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
OwnerAuthor

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?

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
OwnerAuthor

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?

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

type is ambigious


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

| "payment-manager";

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

Copy link
Copy Markdown
OwnerAuthor

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

import type { Project, RemoveResourceInput } from "../types";

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

Copy link
Copy Markdown
OwnerAuthor

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.

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
OwnerAuthor

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
OwnerAuthor

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.

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
OwnerAuthor

Choose a reason for hiding this comment

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

this name is ambiguous

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

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?


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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.

/** 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
OwnerAuthor

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.

input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
OwnerAuthor

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.


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

Copy link
Copy Markdown
OwnerAuthor

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?

return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
OwnerAuthor

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.

return <ResourcePicker project={project} info={info} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
OwnerAuthor

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.

input: RemoveResourceInput;
};

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

Copy link
Copy Markdown
OwnerAuthor

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.

};

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

RemovableResourceTableConfig maybe?

return <RemoveAllConfirm project={project} core={core} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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"

return <ResourceTypePicker project={project} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment

});
const project = resolved.data ?? pinned ?? undefined;

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

Copy link
Copy Markdown
OwnerAuthor

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.

}

// 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
OwnerAuthor

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 freeto 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

@Hweinstock
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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

Conversation

@Hweinstock

@HweinstockHweinstock 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:

TypeParent columnRemoved via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager{ resourceType, name }
gateway-target / gateway-connectorgateway{ resourceType: "gateway-target", gatewayName, name }
policyengine{ resourceType: "policy", engineName, name }
payment-connectormanager{ 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.

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
OwnerAuthor

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.

interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
OwnerAuthor

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


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

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

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
OwnerAuthor

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?

{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
OwnerAuthor

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.

<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

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

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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>>({

@HweinstockHweinstockSep 1, 2026

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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.

// 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
OwnerAuthor

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?

resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
OwnerAuthor

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?

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
OwnerAuthor

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?

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

type is ambigious


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

| "payment-manager";

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

Copy link
Copy Markdown
OwnerAuthor

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

import type { Project, RemoveResourceInput } from "../types";

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

Copy link
Copy Markdown
OwnerAuthor

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.

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
OwnerAuthor

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
OwnerAuthor

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.

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
OwnerAuthor

Choose a reason for hiding this comment

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

this name is ambiguous

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

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?


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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.

/** 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
OwnerAuthor

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.

input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
OwnerAuthor

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.


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

Copy link
Copy Markdown
OwnerAuthor

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?

return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
OwnerAuthor

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.

return <ResourcePicker project={project} info={info} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
OwnerAuthor

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.

input: RemoveResourceInput;
};

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

Copy link
Copy Markdown
OwnerAuthor

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.

};

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

RemovableResourceTableConfig maybe?

return <RemoveAllConfirm project={project} core={core} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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"

return <ResourceTypePicker project={project} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment

});
const project = resolved.data ?? pinned ?? undefined;

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

Copy link
Copy Markdown
OwnerAuthor

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.

}

// 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
OwnerAuthor

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 freeto 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

@Hweinstock
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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

Conversation

@Hweinstock

@HweinstockHweinstock 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:

TypeParent columnRemoved via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager{ resourceType, name }
gateway-target / gateway-connectorgateway{ resourceType: "gateway-target", gatewayName, name }
policyengine{ resourceType: "policy", engineName, name }
payment-connectormanager{ 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.

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
OwnerAuthor

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.

interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
OwnerAuthor

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


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

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

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
OwnerAuthor

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?

{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
OwnerAuthor

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.

<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

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

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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>>({

@HweinstockHweinstockSep 1, 2026

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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.

// 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
OwnerAuthor

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?

resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
OwnerAuthor

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?

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
OwnerAuthor

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?

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

type is ambigious


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

| "payment-manager";

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

Copy link
Copy Markdown
OwnerAuthor

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

import type { Project, RemoveResourceInput } from "../types";

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

Copy link
Copy Markdown
OwnerAuthor

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.

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
OwnerAuthor

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
OwnerAuthor

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.

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
OwnerAuthor

Choose a reason for hiding this comment

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

this name is ambiguous

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

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?


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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.

/** 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
OwnerAuthor

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.

input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
OwnerAuthor

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.


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

Copy link
Copy Markdown
OwnerAuthor

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?

return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
OwnerAuthor

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.

return <ResourcePicker project={project} info={info} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
OwnerAuthor

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.

input: RemoveResourceInput;
};

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

Copy link
Copy Markdown
OwnerAuthor

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.

};

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

RemovableResourceTableConfig maybe?

return <RemoveAllConfirm project={project} core={core} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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"

return <ResourceTypePicker project={project} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment

});
const project = resolved.data ?? pinned ?? undefined;

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

Copy link
Copy Markdown
OwnerAuthor

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.

}

// 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
OwnerAuthor

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 freeto 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

@Hweinstock
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

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

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

Conversation

@Hweinstock

@HweinstockHweinstock 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:

TypeParent columnRemoved via
runtime, harness, memory, credential, config-bundle, online-eval, gateway, policy-engine, payment-manager{ resourceType, name }
gateway-target / gateway-connectorgateway{ resourceType: "gateway-target", gatewayName, name }
policyengine{ resourceType: "policy", engineName, name }
payment-connectormanager{ 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.

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
OwnerAuthor

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.

interface RemovableType {
resourceType: SimpleResourceType;
label: string;
names: (spec: ProjectSpec) => string[];

Copy link
Copy Markdown
OwnerAuthor

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


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

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

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
OwnerAuthor

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?

{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{`'${resourceType}' cannot be removed from the interactive screen.`}</Text>

Copy link
Copy Markdown
OwnerAuthor

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.

<Layout
breadcrumb={["agentcore", "project", "remove"]}
description={`choose a resource to remove from project ${project.name}`}
keyHints={[

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
io: config.io,
});
const removeProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(removeProject);
const removeProjectDispatch: Handler = {

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

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

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/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>>({

@HweinstockHweinstockSep 1, 2026

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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.

// 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
OwnerAuthor

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?

resourceType: "gateway-target",
label: "gateway-target",
parentLabel: "gateway",
getNamedFromSpec: (s) =>

Copy link
Copy Markdown
OwnerAuthor

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?

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
OwnerAuthor

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?

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<TypeRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

type is ambigious


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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
OwnerAuthor

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

| "payment-manager";

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

Copy link
Copy Markdown
OwnerAuthor

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

import type { Project, RemoveResourceInput } from "../types";

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

Copy link
Copy Markdown
OwnerAuthor

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.

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
OwnerAuthor

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
OwnerAuthor

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.

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
OwnerAuthor

Choose a reason for hiding this comment

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

this name is ambiguous

{ key: "count", header: "count", width: 8, align: "right" },
] satisfies DataTableColumn<CategoryRow>[];

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

Copy link
Copy Markdown
OwnerAuthor

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?


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

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

Copy link
Copy Markdown
OwnerAuthor

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
OwnerAuthor

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.

/** 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
OwnerAuthor

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.

input: RemoveResourceInput;
};

type RemovableResourceTypeInfo = {

Copy link
Copy Markdown
OwnerAuthor

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.


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

Copy link
Copy Markdown
OwnerAuthor

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?

return <Navigate to={REMOVE_ROOT} replace />;
}

if (index === undefined) {

Copy link
Copy Markdown
OwnerAuthor

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.

return <ResourcePicker project={project} info={info} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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 threadsrc/handlers/project/index.ts Outdated
createDeployProjectHandler({ projectManager: config.projectManager, io: config.io }),
),
);
project.handler(

Copy link
Copy Markdown
OwnerAuthor

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.

input: RemoveResourceInput;
};

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

Copy link
Copy Markdown
OwnerAuthor

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.

};

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

RemovableResourceTableConfig maybe?

return <RemoveAllConfirm project={project} core={core} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

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"

return <ResourceTypePicker project={project} />;
}

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment.

const { resourceType, resourceIndex } = useParams();
const navigate = useNavigate();

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

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

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

remove this comment

});
const project = resolved.data ?? pinned ?? undefined;

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

Copy link
Copy Markdown
OwnerAuthor

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.

}

// 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
OwnerAuthor

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 freeto 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

@Hweinstock