Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions src/cli/commands/add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface AddGatewayOptions {
agentClientId?: string;
agentClientSecret?: string;
agents?: string;
semanticSearch?: boolean;
json?: boolean;
}

Expand Down
5 changes: 5 additions & 0 deletions src/cli/primitives/GatewayPrimitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface AddGatewayOptions {
agentClientId?: string;
agentClientSecret?: string;
agents?: string;
noSemanticSearch?: boolean;
Comment thread
aidandaly24 marked this conversation as resolved.
Outdated
}

/**
Expand Down Expand Up @@ -156,6 +157,7 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
.option('--agent-client-id <id>', 'Agent OAuth client ID')
.option('--agent-client-secret <secret>', 'Agent OAuth client secret')
.option('--agents <agents>', 'Comma-separated agent names')
.option('--no-semantic-search', 'Disable semantic search for tool discovery')
.option('--json', 'Output as JSON')
.action(async (rawOptions: Record<string, string | boolean | undefined>) => {
const cliOptions = rawOptions as unknown as CLIAddGatewayOptions;
Expand Down Expand Up @@ -186,6 +188,7 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
agentClientId: cliOptions.agentClientId,
agentClientSecret: cliOptions.agentClientSecret,
agents: cliOptions.agents,
noSemanticSearch: cliOptions.semanticSearch === false,
});

if (cliOptions.json) {
Expand Down Expand Up @@ -282,6 +285,7 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
description: options.description ?? `Gateway for ${options.name}`,
authorizerType: options.authorizerType,
jwtConfig: undefined,
enableSemanticSearch: !options.noSemanticSearch,
};

if (options.authorizerType === 'CUSTOM_JWT' && options.discoveryUrl) {
Expand Down Expand Up @@ -348,6 +352,7 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
targets: movedTargets,
authorizerType: config.authorizerType,
authorizerConfiguration: this.buildAuthorizerConfiguration(config),
enableSemanticSearch: config.enableSemanticSearch,
};

mcpSpec.agentCoreGateways.push(gateway);
Expand Down
65 changes: 64 additions & 1 deletion src/cli/tui/hooks/__tests__/useMultiSelectNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMultiSelectNavigation } from '../useMultiSelectNavigation.js';
import { Text } from 'ink';
import { render } from 'ink-testing-library';
import React from 'react';
import React, { useImperativeHandle } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

const UP_ARROW = '\x1B[A';
Expand Down Expand Up @@ -34,13 +34,15 @@ function Harness({
isActive,
textInputActive,
requireSelection,
initialSelectedIds,
}: {
testItems?: Item[];
onConfirm?: (ids: string[]) => void;
onExit?: () => void;
isActive?: boolean;
textInputActive?: boolean;
requireSelection?: boolean;
initialSelectedIds?: string[];
}) {
const { cursorIndex, selectedIds } = useMultiSelectNavigation({
items: testItems,
Expand All @@ -50,6 +52,7 @@ function Harness({
isActive,
textInputActive,
requireSelection,
initialSelectedIds,
});
return (
<Text>
Expand All @@ -58,6 +61,27 @@ function Harness({
);
}

interface ResetHarnessHandle {
reset: () => void;
}

const ResetHarness = React.forwardRef<ResetHarnessHandle, { initialSelectedIds?: string[] }>(
({ initialSelectedIds }, ref) => {
const { cursorIndex, selectedIds, reset } = useMultiSelectNavigation({
items,
getId,
initialSelectedIds,
});
useImperativeHandle(ref, () => ({ reset }));
return (
<Text>
cursor:{cursorIndex} selected:{Array.from(selectedIds).sort().join(',')}
</Text>
);
}
);
ResetHarness.displayName = 'ResetHarness';

describe('useMultiSelectNavigation', () => {
it('starts with cursorIndex=0 and empty selectedIds', () => {
const { lastFrame } = render(<Harness />);
Expand Down Expand Up @@ -217,4 +241,43 @@ describe('useMultiSelectNavigation', () => {
await delay();
expect(onExit).not.toHaveBeenCalled();
});

it('initialSelectedIds pre-selects items', () => {
const { lastFrame } = render(<Harness initialSelectedIds={['1', '3']} />);
expect(lastFrame()).toContain('cursor:0');
expect(lastFrame()).toContain('selected:1,3');
});

it('initialSelectedIds items can be toggled off', async () => {
const { lastFrame, stdin } = render(<Harness initialSelectedIds={['1']} />);
await delay();
expect(lastFrame()).toContain('selected:1');

// Cursor is at 0 (item id '1'), press Space to toggle it off
stdin.write(SPACE);
await delay();
expect(lastFrame()).not.toMatch(/selected:\S/);
});

it('reset restores initialSelectedIds', async () => {
const ref = React.createRef<ResetHarnessHandle>();
const { lastFrame, stdin } = render(<ResetHarness ref={ref} initialSelectedIds={['2']} />);
await delay();
expect(lastFrame()).toContain('selected:2');

// Move cursor to item '2' (index 1) and toggle it off
stdin.write(DOWN_ARROW);
await delay();
stdin.write(SPACE);
await delay();
expect(lastFrame()).not.toMatch(/selected:\S/);

// Trigger reset to restore initialSelectedIds
React.act(() => {
ref.current!.reset();
});
await delay();
expect(lastFrame()).toContain('selected:2');
expect(lastFrame()).toContain('cursor:0');
});
});
1 change: 1 addition & 0 deletions src/cli/tui/hooks/useCreateMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function useCreateGateway() {
allowedScopes: config.jwtConfig?.allowedScopes?.join(','),
agentClientId: config.jwtConfig?.agentClientId,
agentClientSecret: config.jwtConfig?.agentClientSecret,
noSemanticSearch: !config.enableSemanticSearch,
});
if (!addResult.success) {
throw new Error(addResult.error ?? 'Failed to create gateway');
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface UseMultiSelectNavigationOptions<T> {
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
/** Initial set of selected item IDs (default: empty set) */
initialSelectedIds?: string[];
}

interface UseMultiSelectNavigationResult {
Expand Down Expand Up @@ -56,9 +58,10 @@ export function useMultiSelectNavigation<T>({
isActive = true,
textInputActive = false,
requireSelection = false,
initialSelectedIds,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(initialSelectedIds ?? []));

const toggleSelection = useCallback(() => {
const item = items[cursorIndex];
Expand All @@ -77,8 +80,8 @@ export function useMultiSelectNavigation<T>({

const reset = useCallback(() => {
setCursorIndex(0);
setSelectedIds(new Set());
}, []);
setSelectedIds(new Set(initialSelectedIds ?? []));
}, [initialSelectedIds]);

useInput(
(input, key) => {
Expand Down
59 changes: 51 additions & 8 deletions src/cli/tui/screens/mcp/AddGatewayScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { HELP_TEXT } from '../../constants';
import { useListNavigation, useMultiSelectNavigation } from '../../hooks';
import { generateUniqueName } from '../../utils';
import type { AddGatewayConfig } from './types';
import { AUTHORIZER_TYPE_OPTIONS, GATEWAY_STEP_LABELS } from './types';
import { AUTHORIZER_TYPE_OPTIONS, GATEWAY_STEP_LABELS, SEMANTIC_SEARCH_ITEM_ID } from './types';
import { useAddGatewayWizard } from './useAddGatewayWizard';
import { Box, Text } from 'ink';
import React, { useMemo, useState } from 'react';
Expand Down Expand Up @@ -48,10 +48,16 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
[]
);

const advancedConfigItems: SelectableItem[] = useMemo(
() => [{ id: SEMANTIC_SEARCH_ITEM_ID, title: 'Semantic Search' }],
[]
);

const isNameStep = wizard.step === 'name';
const isAuthorizerStep = wizard.step === 'authorizer';
const isJwtConfigStep = wizard.step === 'jwt-config';
const isIncludeTargetsStep = wizard.step === 'include-targets';
const isAdvancedConfigStep = wizard.step === 'advanced-config';
const isConfirmStep = wizard.step === 'confirm';

const authorizerNav = useListNavigation({
Expand All @@ -70,6 +76,17 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
requireSelection: false,
});

const advancedNav = useMultiSelectNavigation({
items: advancedConfigItems,
getId: item => item.id,
initialSelectedIds: [SEMANTIC_SEARCH_ITEM_ID],
Comment thread
aidandaly24 marked this conversation as resolved.
Outdated
onConfirm: selectedIds =>
wizard.setAdvancedConfig({ enableSemanticSearch: selectedIds.includes(SEMANTIC_SEARCH_ITEM_ID) }),
onExit: () => wizard.goBack(),
isActive: isAdvancedConfigStep,
requireSelection: false,
});

useListNavigation({
items: [{ id: 'confirm', title: 'Confirm' }],
onSelect: () => onComplete(wizard.config),
Expand Down Expand Up @@ -136,13 +153,14 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
}
};

const helpText = isIncludeTargetsStep
? 'Space toggle · Enter confirm · Esc back'
: isConfirmStep
? HELP_TEXT.CONFIRM_CANCEL
: isAuthorizerStep
? HELP_TEXT.NAVIGATE_SELECT
: HELP_TEXT.TEXT_INPUT;
const helpText =
isIncludeTargetsStep || isAdvancedConfigStep
? 'Space toggle · Enter confirm · Esc back'
: isConfirmStep
? HELP_TEXT.CONFIRM_CANCEL
: isAuthorizerStep
? HELP_TEXT.NAVIGATE_SELECT
: HELP_TEXT.TEXT_INPUT;

const headerContent = <StepIndicator steps={wizard.steps} currentStep={wizard.step} labels={GATEWAY_STEP_LABELS} />;

Expand Down Expand Up @@ -202,6 +220,30 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
<Text dimColor>No unassigned targets available. Press Enter to continue.</Text>
))}

{isAdvancedConfigStep && (
<Box flexDirection="column">
<Text bold>Advanced Configuration</Text>
<Text dimColor>Toggle options with Space, press Enter to continue</Text>
<Box marginTop={1} flexDirection="column">
{advancedConfigItems.map((item, idx) => {
const isCursor = idx === advancedNav.cursorIndex;
const isChecked = advancedNav.selectedIds.has(item.id);
const checkbox = isChecked ? '[✓]' : '[ ]';
return (
<Box key={item.id}>
<Text wrap="truncate">
<Text color={isCursor ? 'cyan' : undefined}>{isCursor ? '❯' : ' '} </Text>
<Text color={isChecked ? 'green' : undefined}>{checkbox} </Text>
<Text color={isCursor ? 'cyan' : undefined}>{item.title}</Text>
</Text>
<Text dimColor> {isChecked ? 'Enabled' : 'Disabled'}</Text>
</Box>
);
})}
</Box>
</Box>
)}

{isConfirmStep && (
<ConfirmReview
fields={[
Expand All @@ -228,6 +270,7 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
? wizard.config.selectedTargets.join(', ')
: '(none)',
},
{ label: 'Semantic Search', value: wizard.config.enableSemanticSearch ? 'Enabled' : 'Disabled' },
]}
/>
)}
Expand Down
8 changes: 7 additions & 1 deletion src/cli/tui/screens/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { TARGET_TYPE_AUTH_CONFIG } from '../../../../schema';
// Gateway Flow Types
// ─────────────────────────────────────────────────────────────────────────────

export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'include-targets' | 'confirm';
export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'include-targets' | 'advanced-config' | 'confirm';

export interface AddGatewayConfig {
name: string;
Expand All @@ -31,13 +31,19 @@ export interface AddGatewayConfig {
};
/** Selected unassigned targets to include in this gateway */
selectedTargets?: string[];
/** Whether to enable semantic search for tool discovery */
enableSemanticSearch: boolean;
}

/** Item ID for the semantic search toggle in the advanced config pane. */
export const SEMANTIC_SEARCH_ITEM_ID = 'semantic-search';

export const GATEWAY_STEP_LABELS: Record<AddGatewayStep, string> = {
name: 'Name',
authorizer: 'Authorizer',
'jwt-config': 'JWT Config',
'include-targets': 'Include Targets',
'advanced-config': 'Advanced',
confirm: 'Confirm',
};

Expand Down
Loading
Loading