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
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;
enableSemanticSearch?: boolean;
}

/**
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,
enableSemanticSearch: 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.enableSemanticSearch ?? true,
};

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,
enableSemanticSearch: 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
61 changes: 53 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 All @@ -27,6 +27,8 @@ interface AddGatewayScreenProps {
unassignedTargets: string[];
}

const INITIAL_ADVANCED_SELECTED = [SEMANTIC_SEARCH_ITEM_ID];

export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassignedTargets }: AddGatewayScreenProps) {
const wizard = useAddGatewayWizard(unassignedTargets.length);

Expand All @@ -48,10 +50,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 +78,17 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
requireSelection: false,
});

const advancedNav = useMultiSelectNavigation({
items: advancedConfigItems,
getId: item => item.id,
initialSelectedIds: INITIAL_ADVANCED_SELECTED,
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 +155,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 +222,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 +272,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