diff --git a/ui/bun.lock b/ui/bun.lock
index f2f7ca2e..c677c243 100644
--- a/ui/bun.lock
+++ b/ui/bun.lock
@@ -12,6 +12,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
@@ -245,6 +246,8 @@
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
+ "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
+
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
@@ -263,6 +266,8 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+ "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
+
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
@@ -807,6 +812,8 @@
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
diff --git a/ui/package.json b/ui/package.json
index 7e02cbdb..9b90a795 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -23,6 +23,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx
index 16243dc6..d379a7c9 100644
--- a/ui/src/components/code-editor.tsx
+++ b/ui/src/components/code-editor.tsx
@@ -4,12 +4,14 @@
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
*/
-import { useState, useCallback, useMemo } from 'react';
+import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import Editor from 'react-simple-code-editor';
import { Highlight, themes } from 'prism-react-renderer';
import { useTheme } from '@/hooks/use-theme';
import { cn } from '@/lib/utils';
-import { AlertCircle, CheckCircle2 } from 'lucide-react';
+import { isSensitiveKey } from '@/lib/sensitive-keys';
+import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react';
+import { Button } from '@/components/ui/button';
interface CodeEditorProps {
value: string;
@@ -71,6 +73,19 @@ export function CodeEditor({
}: CodeEditorProps) {
const { isDark } = useTheme();
const [isFocused, setIsFocused] = useState(false);
+ const [isMasked, setIsMasked] = useState(true);
+ // Force Editor remount when theme changes (works around react-simple-code-editor caching)
+ const [editorKey, setEditorKey] = useState(0);
+ const isFirstRender = useRef(true);
+
+ useEffect(() => {
+ // Skip first render, only trigger on theme changes
+ if (isFirstRender.current) {
+ isFirstRender.current = false;
+ return;
+ }
+ setEditorKey((k) => k + 1);
+ }, [isDark]);
// Validate on every change for JSON
const validation = useMemo(() => {
@@ -81,32 +96,76 @@ export function CodeEditor({
}, [value, language]);
// Highlight function using prism-react-renderer
+ // Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
const highlightCode = useCallback(
(code: string) => (
- {({ tokens, getLineProps, getTokenProps }) => (
- <>
- {tokens.map((line, i) => (
-
-
- {i + 1}
-
-
- {line.map((token, key) => (
-
- ))}
-
-
- ))}
- >
- )}
+ {({ tokens, getLineProps, getTokenProps }) => {
+ let nextValueIsSensitive = false;
+
+ return (
+ <>
+ {tokens.map((line, i) => (
+
+ {line.map((token, key) => {
+ let isSensitive = false;
+
+ // Check for sensitive keys
+ if (token.types.includes('property')) {
+ const content = token.content.replace(/['"]/g, '');
+ // Use shared sensitive key detection utility
+ if (isSensitiveKey(content)) {
+ nextValueIsSensitive = true;
+ } else {
+ nextValueIsSensitive = false;
+ }
+ }
+ // Apply masking to values following sensitive keys
+ else if (
+ (token.types.includes('string') ||
+ token.types.includes('number') ||
+ token.types.includes('boolean')) &&
+ nextValueIsSensitive
+ ) {
+ isSensitive = true;
+ // Consumes the flag for this value
+ nextValueIsSensitive = false;
+ }
+ // Reset flag on commas or new keys (handled by property check),
+ // but persist through colons and whitespace
+ else if (token.types.includes('punctuation')) {
+ if (
+ token.content !== ':' &&
+ token.content !== '[' &&
+ token.content !== '{'
+ ) {
+ nextValueIsSensitive = false;
+ }
+ }
+
+ const tokenProps = getTokenProps({ token });
+
+ if (isSensitive && isMasked) {
+ tokenProps.className = cn(
+ tokenProps.className,
+ 'blur-[3px] select-none opacity-70 transition-all duration-200'
+ );
+ }
+
+ return ;
+ })}
+
+ ))}
+ >
+ );
+ }}
),
- [isDark, language, validation.line]
+ [isDark, language, validation.line, isMasked]
);
return (
@@ -126,7 +185,7 @@ export function CodeEditor({
value={value}
onValueChange={readonly ? () => {} : onChange}
highlight={highlightCode}
- key={isDark ? 'dark' : 'light'}
+ key={editorKey}
padding={12}
disabled={readonly}
onFocus={() => setIsFocused(true)}
@@ -142,6 +201,19 @@ export function CodeEditor({
minHeight,
}}
/>
+
+ {/* Secrets Toggle Overlay */}
+
+
+
{/* Validation status */}
diff --git a/ui/src/components/profile-create-dialog.tsx b/ui/src/components/profile-create-dialog.tsx
new file mode 100644
index 00000000..5506bd45
--- /dev/null
+++ b/ui/src/components/profile-create-dialog.tsx
@@ -0,0 +1,344 @@
+/**
+ * Profile Create Dialog Component
+ * Modal dialog with tabbed interface for creating new API profiles
+ * Includes Quick Start templates and advanced model configuration
+ */
+
+import { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Badge } from '@/components/ui/badge';
+import { useCreateProfile } from '@/hooks/use-profiles';
+import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react';
+import { toast } from 'sonner';
+import { cn } from '@/lib/utils';
+
+const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
+
+const schema = z.object({
+ name: z
+ .string()
+ .min(1, 'Name is required')
+ .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'),
+ baseUrl: z.string().url('Invalid URL format'),
+ apiKey: z.string().min(1, 'API key is required'),
+ model: z.string().optional(),
+ opusModel: z.string().optional(),
+ sonnetModel: z.string().optional(),
+ haikuModel: z.string().optional(),
+});
+
+type FormData = z.infer;
+
+interface ProfileCreateDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess: (name: string) => void;
+}
+
+// Common URL mistakes to warn about
+const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
+
+export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) {
+ const createMutation = useCreateProfile();
+ const [activeTab, setActiveTab] = useState('basic');
+ const [urlWarning, setUrlWarning] = useState(null);
+ const [showApiKey, setShowApiKey] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ watch,
+ reset,
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ name: '',
+ baseUrl: '',
+ apiKey: '',
+ model: '',
+ opusModel: '',
+ sonnetModel: '',
+ haikuModel: '',
+ },
+ });
+
+ const baseUrlValue = watch('baseUrl');
+
+ // Reset form when dialog opens
+ useEffect(() => {
+ if (open) {
+ reset();
+ setActiveTab('basic');
+ setUrlWarning(null);
+ setShowApiKey(false);
+ }
+ }, [open, reset]);
+
+ // Check for common URL mistakes
+ useEffect(() => {
+ if (baseUrlValue) {
+ const lowerUrl = baseUrlValue.toLowerCase();
+ for (const path of PROBLEMATIC_PATHS) {
+ if (lowerUrl.endsWith(path)) {
+ const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), '');
+ setUrlWarning(
+ `URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}`
+ );
+ return;
+ }
+ }
+ }
+ setUrlWarning(null);
+ }, [baseUrlValue]);
+
+ const onSubmit = async (data: FormData) => {
+ try {
+ await createMutation.mutateAsync(data);
+ toast.success(`Profile "${data.name}" created`);
+ onSuccess(data.name);
+ onOpenChange(false);
+ } catch (error) {
+ toast.error((error as Error).message || 'Failed to create profile');
+ }
+ };
+
+ const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
+ const hasModelErrors =
+ !!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/profile-create-form.tsx b/ui/src/components/profile-create-form.tsx
deleted file mode 100644
index 8674ecf0..00000000
--- a/ui/src/components/profile-create-form.tsx
+++ /dev/null
@@ -1,319 +0,0 @@
-/**
- * Profile Create Form Component
- * Inline form for creating new API profiles with model mapping
- */
-
-import { useState, useEffect } from 'react';
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
-import * as z from 'zod';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { ScrollArea } from '@/components/ui/scroll-area';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Badge } from '@/components/ui/badge';
-import { useCreateProfile } from '@/hooks/use-profiles';
-import {
- ArrowLeft,
- Loader2,
- Plus,
- ChevronDown,
- ChevronRight,
- AlertTriangle,
- HelpCircle,
-} from 'lucide-react';
-import { toast } from 'sonner';
-
-const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
-
-const schema = z.object({
- name: z
- .string()
- .min(1, 'Name is required')
- .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'),
- baseUrl: z.string().url('Invalid URL format'),
- apiKey: z.string().min(10, 'API key must be at least 10 characters'),
- model: z.string().optional(),
- opusModel: z.string().optional(),
- sonnetModel: z.string().optional(),
- haikuModel: z.string().optional(),
-});
-
-type FormData = z.infer;
-
-interface ProfileCreateFormProps {
- onSuccess: (name: string) => void;
- onCancel: () => void;
-}
-
-// Common URL mistakes to warn about
-const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
-
-export function ProfileCreateForm({ onSuccess, onCancel }: ProfileCreateFormProps) {
- const createMutation = useCreateProfile();
- const [showModelMapping, setShowModelMapping] = useState(false);
- const [urlWarning, setUrlWarning] = useState(null);
-
- const {
- register,
- handleSubmit,
- formState: { errors },
- watch,
- } = useForm({
- resolver: zodResolver(schema),
- defaultValues: {
- model: '',
- opusModel: '',
- sonnetModel: '',
- haikuModel: '',
- },
- });
-
- const modelValue = watch('model');
- const baseUrlValue = watch('baseUrl');
-
- // Auto-expand model mapping when custom model is entered
- useEffect(() => {
- if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') {
- setShowModelMapping(true);
- }
- }, [modelValue]);
-
- // Check for common URL mistakes
- useEffect(() => {
- if (baseUrlValue) {
- const lowerUrl = baseUrlValue.toLowerCase();
- for (const path of PROBLEMATIC_PATHS) {
- if (lowerUrl.endsWith(path)) {
- const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), '');
- setUrlWarning(
- `URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}`
- );
- return;
- }
- }
- }
- setUrlWarning(null);
- }, [baseUrlValue]);
-
- const onSubmit = async (data: FormData) => {
- try {
- await createMutation.mutateAsync(data);
- toast.success(`Profile "${data.name}" created`);
- onSuccess(data.name);
- } catch (error) {
- toast.error((error as Error).message || 'Failed to create profile');
- }
- };
-
- return (
-
- {/* Header */}
-
-
-
-
Create API Profile
-
Configure a new custom API endpoint
-
-
-
-
-
-
-
- );
-}
diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx
new file mode 100644
index 00000000..fad1c32d
--- /dev/null
+++ b/ui/src/components/ui/select.tsx
@@ -0,0 +1,150 @@
+import * as React from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { Check, ChevronDown, ChevronUp } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1',
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = 'popper', ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/ui/src/lib/sensitive-keys.ts b/ui/src/lib/sensitive-keys.ts
new file mode 100644
index 00000000..c4f1cc98
--- /dev/null
+++ b/ui/src/lib/sensitive-keys.ts
@@ -0,0 +1,35 @@
+/**
+ * Sensitive Key Detection Utilities (UI)
+ *
+ * Re-exports from main package for use in UI components.
+ * Patterns detect API keys, tokens, passwords, etc.
+ */
+
+/**
+ * Patterns that match sensitive keys (API keys, tokens, passwords).
+ * More specific than substring matching to avoid false positives.
+ */
+export const SENSITIVE_KEY_PATTERNS = [
+ /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token
+ /_API_KEY$/, // Keys ending with _API_KEY
+ /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN
+ /_SECRET$/, // Keys ending with _SECRET
+ /_SECRET_KEY$/, // Keys ending with _SECRET_KEY
+ /^API_KEY$/, // Exact match for API_KEY
+ /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN
+ /^SECRET$/, // Exact match for SECRET
+ /_PASSWORD$/, // Keys ending with _PASSWORD
+ /^PASSWORD$/, // Exact match for PASSWORD
+ /_CREDENTIAL$/, // Keys ending with _CREDENTIAL
+ /_PRIVATE_KEY$/, // Keys ending with _PRIVATE_KEY
+];
+
+/**
+ * Check if a key name contains a secret/sensitive value.
+ *
+ * @param key - Environment variable key name
+ * @returns true if the key likely contains sensitive data
+ */
+export function isSensitiveKey(key: string): boolean {
+ return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
+}
diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx
index aa21b1d2..cde4a4d6 100644
--- a/ui/src/pages/api.tsx
+++ b/ui/src/pages/api.tsx
@@ -21,7 +21,7 @@ import {
FileJson,
} from 'lucide-react';
import { ProfileEditor } from '@/components/profile-editor';
-import { ProfileCreateForm } from '@/components/profile-create-form';
+import { ProfileCreateDialog } from '@/components/profile-create-dialog';
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
import { ConfirmDialog } from '@/components/confirm-dialog';
import type { Profile } from '@/lib/api-client';
@@ -33,7 +33,7 @@ export function ApiPage() {
const deleteMutation = useDeleteProfile();
const [selectedProfile, setSelectedProfile] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
- const [showCreateForm, setShowCreateForm] = useState(false);
+ const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
// Memoize profiles to maintain stable reference
@@ -47,12 +47,11 @@ export function ApiPage() {
// Compute effective selected profile (auto-select first if none selected)
const effectiveSelectedProfile = useMemo(() => {
- if (showCreateForm) return null;
if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) {
return selectedProfile;
}
return profiles.length > 0 ? profiles[0].name : null;
- }, [selectedProfile, profiles, showCreateForm]);
+ }, [selectedProfile, profiles]);
// Handle profile deletion
const handleDelete = (name: string) => {
@@ -68,7 +67,7 @@ export function ApiPage() {
// Handle create success
const handleCreateSuccess = (name: string) => {
- setShowCreateForm(false);
+ setCreateDialogOpen(false);
setSelectedProfile(name);
};
@@ -88,8 +87,7 @@ export function ApiPage() {