Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/components/FormRadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@ export interface FormRadioGroupProps {
name: string;
helpText: string;
options: FormRadioOption[];
selectedIndex: number;
// highlighted/hovered row
focusedIndex: number;
// row that user selects / hits ENTER on
selectedIndex?: number;
}

// FormRadioGroup renders a column of radio rows. It is fully controlled: the
// parent owns the selected index and the key handling that moves it.
export function FormRadioGroup({ name, helpText, options, selectedIndex }: FormRadioGroupProps) {
// parent owns the focused index and the key handling that moves it.
export function FormRadioGroup({
name,
helpText,
options,
focusedIndex,
selectedIndex,
}: FormRadioGroupProps) {
const columnWidth = options.reduce((max, option) => Math.max(max, option.label.length), 0) + 2;

return (
Expand All @@ -33,16 +42,23 @@ export function FormRadioGroup({ name, helpText, options, selectedIndex }: FormR
borderColor={theme.colors.border}
>
{options.map((option, i) => {
const focused = i === focusedIndex;
const selected = i === selectedIndex;
// A selected row uses the selection color; a hovered (focused) row
// uses the brighter focus color; everything else is neutral.
const accentColor = selected
? theme.colors.selection
: focused
? theme.colors.focus
: undefined;
const highlighted = focused || selected;
return (
<Box key={option.label} flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={selected ? theme.colors.focus : theme.colors.muted}>
{selected ? "●" : "○"}
</Text>
<Text color={accentColor ?? theme.colors.muted}>{highlighted ? "●" : "○"}</Text>
</Box>
<Box width={columnWidth} flexShrink={0}>
<Text bold={selected} color={selected ? theme.colors.focus : theme.colors.text}>
<Text bold={highlighted} color={accentColor ?? theme.colors.text}>
{option.label}
</Text>
</Box>
Expand Down
36 changes: 19 additions & 17 deletions src/components/HarnessWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -755,23 +755,25 @@ function ModelStep({
name="choose a model"
helpText="the provider and model that will power the harness"
options={rows}
selectedIndex={index}
focusedIndex={index}
selectedIndex={focusedField !== null ? index : undefined}
/>
{provider.fields.map((field, i) => (
<FormTextInput
key={`${provider.kind}.${field.key}`}
name={field.name}
helpText={field.helpText}
placeholder={field.placeholder}
errorText=""
value={value[field.key]}
onChange={(next) => {
onChange({ ...value, [field.key]: next });
setError(null);
}}
focused={focusedField === i}
/>
))}
{focusedField !== null &&
provider.fields.map((field, i) => (
<FormTextInput
key={`${provider.kind}.${field.key}`}
name={field.name}
helpText={field.helpText}
placeholder={field.placeholder}
errorText=""
value={value[field.key]}
onChange={(next) => {
onChange({ ...value, [field.key]: next });
setError(null);
}}
focused={focusedField === i}
/>
))}
{error && <Text color={theme.colors.error}>{error}</Text>}
{index !== 0 && (
<Text color={theme.colors.info}>
Expand Down Expand Up @@ -861,7 +863,7 @@ function MemoryStep({
name="choose a memory configuration"
helpText="how should the harness remember conversations?"
options={MEMORY_OPTIONS}
selectedIndex={index}
focusedIndex={index}
/>
{value.kind === "byo" && (
<FormTextInput
Expand Down
21 changes: 21 additions & 0 deletions src/handlers/harness/create/create.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ describe("harness create wizard", () => {
r.unmount();
});

test("reveals model fields only after enter and hides them again on escape", async () => {
const r = renderScreen("/agentcore/harness/create", { core: coreForCreate() });

await waitForText(r.lastFrame, "the name of your harness");
await r.write("my_agent");
await r.press("return");

await waitForText(r.lastFrame, "choose a model");
await r.press("down"); // bedrock
await waitForText(r.lastFrame, "● bedrock");
expect(r.lastFrame()).not.toContain("model id");

await r.press("return");
await waitForText(r.lastFrame, "model id");

await r.press("escape");
await waitFor(() => !(r.lastFrame() ?? "").includes("model id"));
expect(r.lastFrame()).toContain("● bedrock");
r.unmount();
});

test("selecting gemini collects the model id and api key arn", async () => {
const core = coreForCreate();
const r = renderScreen("/agentcore/harness/create", { core });
Expand Down
6 changes: 4 additions & 2 deletions src/handlers/harness/update/update.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,12 @@ describe("harness update wizard", () => {
core.harness.setGetResponse(current);
const r = renderScreen("/agentcore/harness/update/MyHarness-abc123", { core });

// The harness's bedrock provider is preselected with its model id prefilled.
// The harness's bedrock provider is preselected. Its persisted model id is
// revealed after confirming the provider.
await waitForText(r.lastFrame, "● bedrock");
expect(r.lastFrame()).toContain("us.anthropic.claude-opus-4-8");
expect(r.lastFrame()).not.toContain("us.anthropic.claude-opus-4-8");
await r.press("return"); // into the model id field
await waitForText(r.lastFrame, "us.anthropic.claude-opus-4-8");
await r.press("return"); // accept it unchanged
await waitForText(r.lastFrame, "● managed");
await r.press("return");
Expand Down
81 changes: 49 additions & 32 deletions src/handlers/memory/record/list/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,33 +66,48 @@ interface MemoryRecordScopeScreenProps {

function MemoryRecordScopeScreen({ memoryId }: MemoryRecordScopeScreenProps) {
const navigate = useNavigate();
const [selectedIndex, setSelectedIndex] = useState(0);
const [focusedIndex, setFocusedIndex] = useState(0);
const [scope, setScope] = useState("");
const [submitted, setSubmitted] = useState(false);

const submit = (value: string) => {
if (value.trim() === "") {
setSubmitted(true);
return;
}

const kind: RecordScopeKind = selectedIndex === 0 ? "namespace" : "namespace-path";
navigate(
`/agentcore/memory/record/list/${encodeURIComponent(memoryId)}/${kind}/${encodeURIComponent(value)}`,
);
};
// editing is true while the scope text field has focus; the radio list has
// focus otherwise.
const [editing, setEditing] = useState(false);

useInput((_input, key) => {
if (key.escape) {
navigate(-1);
if (!editing) {
if (key.escape) {
navigate(-1);
return;
}
if (key.upArrow) {
setFocusedIndex(0);
return;
}
if (key.downArrow) {
setFocusedIndex(1);
return;
}
if (key.return) {
setEditing(true);
}
return;
}
if (key.upArrow) {
setSelectedIndex(0);

// The scope field is focused; its TextInput owns text editing.
if (key.escape || key.upArrow) {
setEditing(false);
setSubmitted(false);
return;
}
if (key.downArrow) {
setSelectedIndex(1);
if (key.return) {
if (scope.trim() === "") {
setSubmitted(true);
return;
}
const kind: RecordScopeKind = focusedIndex === 0 ? "namespace" : "namespace-path";
navigate(
`/agentcore/memory/record/list/${encodeURIComponent(memoryId)}/${kind}/${encodeURIComponent(scope)}`,
);
}
});

Expand All @@ -112,20 +127,22 @@ function MemoryRecordScopeScreen({ memoryId }: MemoryRecordScopeScreenProps) {
name="scope type"
helpText="Choose how the service should match record namespaces."
options={scopeOptions}
selectedIndex={selectedIndex}
/>
<FormTextInput
name={selectedIndex === 0 ? "namespace" : "namespace path"}
helpText="Enter the namespace value used to scope this request."
placeholder="/strategies/strategy-id/actors/actor-id"
errorText="A namespace value is required."
value={scope}
onChange={(value) => {
setScope(value);
setSubmitted(false);
}}
onSubmit={submit}
focusedIndex={focusedIndex}
selectedIndex={editing ? focusedIndex : undefined}
/>
{editing && (
<FormTextInput
name={focusedIndex === 0 ? "namespace" : "namespace path"}
helpText="Enter the namespace value used to scope this request."
placeholder="/strategies/strategy-id/actors/actor-id"
errorText="A namespace value is required."
value={scope}
onChange={(value) => {
setScope(value);
setSubmitted(false);
}}
/>
)}
{submitted && scope.trim() === "" ? (
<Text color={darkTheme.colors.error}>A namespace value is required.</Text>
) : null}
Expand Down
22 changes: 21 additions & 1 deletion src/handlers/memory/record/record.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ describe("Memory record list flow", () => {
expect(core.memory.calls.some((call) => call.method === "listMemoryRecords")).toBe(false);
});

test("reveals the scope input only after enter and hides it again on escape", async () => {
const screen = renderScreen("/agentcore/memory/record/list/memory-1");
const fieldHelp = "Enter the namespace value used to scope this request.";

await waitForText(screen.lastFrame, "scope type");
expect(screen.lastFrame()).not.toContain(fieldHelp);

await screen.press("return");
await waitForText(screen.lastFrame, fieldHelp);

await screen.press("escape");
await waitFor(() => !(screen.lastFrame() ?? "").includes(fieldHelp));
expect(screen.core.memory.calls).toEqual([]);
});

test("unwinds the record table through its scope and Memory pickers", async () => {
const memoryId = "memory/blue one";
const core = new TestCoreClient();
Expand All @@ -85,6 +100,7 @@ describe("Memory record list flow", () => {
await waitForText(screen.lastFrame, memoryId);
await screen.press("return");
await waitForText(screen.lastFrame, "scope type");
await screen.press("return"); // focus the namespace input
await screen.write("/customers/acme");
await screen.press("return");
await waitForText(screen.lastFrame, "Customer prefers email notifications.");
Expand All @@ -105,6 +121,7 @@ describe("Memory record list flow", () => {
await waitForText(screen.lastFrame, "scope type");
await screen.press("down");
await screen.press("up");
await screen.press("return"); // focus the namespace input
await screen.write("/customers/acme");
await screen.press("return");
await waitFor(() => core.memory.calls.some((call) => call.method === "listMemoryRecords"));
Expand All @@ -128,6 +145,7 @@ describe("Memory record list flow", () => {
});

await waitForText(screen.lastFrame, "scope type");
await screen.press("return"); // focus the namespace input
await screen.write("/customers/acme");
await screen.press("return");
await waitForText(screen.lastFrame, "Customer prefers email notifications.");
Expand Down Expand Up @@ -181,6 +199,7 @@ describe("Memory record list flow", () => {

await waitForText(screen.lastFrame, "scope type");
await screen.press("down");
await screen.press("return"); // focus the namespace path input
await screen.write("/customers/acme/*");
await screen.press("return");
await waitFor(() => core.memory.calls.some((call) => call.method === "listMemoryRecords"));
Expand Down Expand Up @@ -317,7 +336,8 @@ describe("Memory record list flow", () => {
const screen = renderScreen("/agentcore/memory/record/list/memory-1");

await waitForText(screen.lastFrame, "scope type");
await screen.press("return");
await screen.press("return"); // focus the namespace input
await screen.press("return"); // submit the empty value
await waitForText(screen.lastFrame, "A namespace value is required.");
expect(screen.core.memory.calls).toEqual([]);
});
Expand Down
3 changes: 0 additions & 3 deletions src/handlers/project/buildDeploy.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,6 @@ function fakeBackend(options: FakeBackendOptions = {}) {
async resolveDeployedResources() {
return [];
},
async resolveProjectResources() {
return [];
},
};
return { backend, deploys };
}
Expand Down
Loading
Loading